diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 7cd04ce3..24711c4a 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -6,11 +6,11 @@ runs:
steps:
# Setup the SDKs
- name: Set up Python
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Setup JDK
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
with:
distribution: zulu
java-version: '21'
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index cafd4889..3bd2e54d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -77,7 +77,7 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mapping
path: |
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index e423cccf..9e518406 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -20,7 +20,7 @@ jobs:
with:
fetch-depth: 0 # Need the tags to build
- name: Setup Python
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Run pre-commit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5b108ce5..4822ce7e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -59,14 +59,14 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: AAB
path: |
app/build/outputs/bundle/**/*.aab
compression-level: 0
- name: Upload mapping files
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mapping
path: |
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 96883e40..6178f8ea 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -315,7 +315,6 @@ dependencies {
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
- implementation(libs.exoplayerhdrutils)
implementation(libs.coil.core)
implementation(libs.coil.compose)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index b2a0c548..cddfb043 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -22,6 +22,3 @@
-keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth;
}
-
-# Keep external/native functions
--keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json
deleted file mode 100644
index 3e874e4e..00000000
--- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json
+++ /dev/null
@@ -1,834 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 40,
- "identityHash": "b297c087485321c3ee8282ad1ff675a0",
- "entities": [
- {
- "tableName": "servers",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "version",
- "columnName": "version",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "lastUsed",
- "columnName": "lastUsed",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "rowId",
- "columnName": "rowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "serverId",
- "columnName": "serverId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "accessToken",
- "columnName": "accessToken",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "pin",
- "columnName": "pin",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "requireLogin",
- "columnName": "requireLogin",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "false"
- },
- {
- "fieldPath": "lastUsed",
- "columnName": "lastUsed",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "uiLanguage",
- "columnName": "uiLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "appPreferences.preferredAudioLanguage",
- "columnName": "preferredAudioLanguage",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "appPreferences.preferredSubtitleLanguage",
- "columnName": "preferredSubtitleLanguage",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "appPreferences.subtitleMode",
- "columnName": "subtitleMode",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'USE_USER_PROFILE'"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "rowId"
- ]
- },
- "indices": [
- {
- "name": "index_users_id_serverId",
- "unique": true,
- "columnNames": [
- "id",
- "serverId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
- },
- {
- "name": "index_users_id",
- "unique": false,
- "columnNames": [
- "id"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
- },
- {
- "name": "index_users_serverId",
- "unique": false,
- "columnNames": [
- "serverId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "servers",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "serverId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "ItemPlayback",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "rowId",
- "columnName": "rowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "sourceId",
- "columnName": "sourceId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioIndex",
- "columnName": "audioIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "subtitleIndex",
- "columnName": "subtitleIndex",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "rowId"
- ]
- },
- "indices": [
- {
- "name": "index_ItemPlayback_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "NavDrawerPinnedItem",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "-1"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "LibraryDisplayInfo",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "sort",
- "columnName": "sort",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "direction",
- "columnName": "direction",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "filter",
- "columnName": "filter",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'{}'"
- },
- {
- "fieldPath": "viewOptions",
- "columnName": "viewOptions",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "indices": [
- {
- "name": "index_LibraryDisplayInfo_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "playback_effects",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
- "fields": [
- {
- "fieldPath": "jellyfinUserRowId",
- "columnName": "jellyfinUserRowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.rotation",
- "columnName": "rotation",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.brightness",
- "columnName": "brightness",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.contrast",
- "columnName": "contrast",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.saturation",
- "columnName": "saturation",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.hue",
- "columnName": "hue",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.red",
- "columnName": "red",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.green",
- "columnName": "green",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.blue",
- "columnName": "blue",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.blur",
- "columnName": "blur",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "jellyfinUserRowId",
- "itemId",
- "type"
- ]
- }
- },
- {
- "tableName": "PlaybackLanguageChoice",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "seriesId",
- "columnName": "seriesId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioLanguage",
- "columnName": "audioLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "subtitleLanguage",
- "columnName": "subtitleLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "subtitlesDisabled",
- "columnName": "subtitlesDisabled",
- "affinity": "INTEGER"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "seriesId"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "ItemTrackModification",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "trackIndex",
- "columnName": "trackIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "delayMs",
- "columnName": "delayMs",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId",
- "trackIndex"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "seerr_servers",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "version",
- "columnName": "version",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_seerr_servers_url",
- "unique": true,
- "columnNames": [
- "url"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
- }
- ]
- },
- {
- "tableName": "seerr_users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "jellyfinUserRowId",
- "columnName": "jellyfinUserRowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "serverId",
- "columnName": "serverId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "authMethod",
- "columnName": "authMethod",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "username",
- "columnName": "username",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "password",
- "columnName": "password",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "credential",
- "columnName": "credential",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "jellyfinUserRowId",
- "serverId"
- ]
- },
- "foreignKeys": [
- {
- "table": "seerr_servers",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "serverId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "jellyfinUserRowId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "RememberedTab",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "index",
- "columnName": "index",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "indices": [
- {
- "name": "index_RememberedTab_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "SeriesTrackChoice",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "parentId",
- "columnName": "parentId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "parentType",
- "columnName": "parentType",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "language",
- "columnName": "language",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "activation",
- "columnName": "activation",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "trackFlags",
- "columnName": "trackFlags",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "codec",
- "columnName": "codec",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "relativeTrackIndex",
- "columnName": "relativeTrackIndex",
- "affinity": "INTEGER"
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioChannels",
- "columnName": "audioChannels",
- "affinity": "INTEGER"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "parentId",
- "type"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- }
- ],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b297c087485321c3ee8282ad1ff675a0')"
- ]
- }
-}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json
deleted file mode 100644
index 4a41a052..00000000
--- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json
+++ /dev/null
@@ -1,841 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 41,
- "identityHash": "1f2fe7912f9e3ab83b77029e07488e84",
- "entities": [
- {
- "tableName": "servers",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "version",
- "columnName": "version",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "lastUsed",
- "columnName": "lastUsed",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "rowId",
- "columnName": "rowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "serverId",
- "columnName": "serverId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "accessToken",
- "columnName": "accessToken",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "pin",
- "columnName": "pin",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "requireLogin",
- "columnName": "requireLogin",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "false"
- },
- {
- "fieldPath": "lastUsed",
- "columnName": "lastUsed",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "uiLanguage",
- "columnName": "uiLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "appPreferences.preferredAudioLanguage",
- "columnName": "preferredAudioLanguage",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "appPreferences.preferredSubtitleLanguage",
- "columnName": "preferredSubtitleLanguage",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "appPreferences.subtitleMode",
- "columnName": "subtitleMode",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'USE_USER_PROFILE'"
- },
- {
- "fieldPath": "config.homeSettingsSource",
- "columnName": "c_homeSettingsSource",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'UNSET'"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "rowId"
- ]
- },
- "indices": [
- {
- "name": "index_users_id_serverId",
- "unique": true,
- "columnNames": [
- "id",
- "serverId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
- },
- {
- "name": "index_users_id",
- "unique": false,
- "columnNames": [
- "id"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
- },
- {
- "name": "index_users_serverId",
- "unique": false,
- "columnNames": [
- "serverId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "servers",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "serverId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "ItemPlayback",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "rowId",
- "columnName": "rowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "sourceId",
- "columnName": "sourceId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioIndex",
- "columnName": "audioIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "subtitleIndex",
- "columnName": "subtitleIndex",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "rowId"
- ]
- },
- "indices": [
- {
- "name": "index_ItemPlayback_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "NavDrawerPinnedItem",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "-1"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "LibraryDisplayInfo",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "sort",
- "columnName": "sort",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "direction",
- "columnName": "direction",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "filter",
- "columnName": "filter",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'{}'"
- },
- {
- "fieldPath": "viewOptions",
- "columnName": "viewOptions",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "indices": [
- {
- "name": "index_LibraryDisplayInfo_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "playback_effects",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
- "fields": [
- {
- "fieldPath": "jellyfinUserRowId",
- "columnName": "jellyfinUserRowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.rotation",
- "columnName": "rotation",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.brightness",
- "columnName": "brightness",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.contrast",
- "columnName": "contrast",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.saturation",
- "columnName": "saturation",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.hue",
- "columnName": "hue",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.red",
- "columnName": "red",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.green",
- "columnName": "green",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.blue",
- "columnName": "blue",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "videoFilter.blur",
- "columnName": "blur",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "jellyfinUserRowId",
- "itemId",
- "type"
- ]
- }
- },
- {
- "tableName": "PlaybackLanguageChoice",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "seriesId",
- "columnName": "seriesId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioLanguage",
- "columnName": "audioLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "subtitleLanguage",
- "columnName": "subtitleLanguage",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "subtitlesDisabled",
- "columnName": "subtitlesDisabled",
- "affinity": "INTEGER"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "seriesId"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "ItemTrackModification",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "trackIndex",
- "columnName": "trackIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "delayMs",
- "columnName": "delayMs",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId",
- "trackIndex"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "seerr_servers",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "url",
- "columnName": "url",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "version",
- "columnName": "version",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_seerr_servers_url",
- "unique": true,
- "columnNames": [
- "url"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
- }
- ]
- },
- {
- "tableName": "seerr_users",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "jellyfinUserRowId",
- "columnName": "jellyfinUserRowId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "serverId",
- "columnName": "serverId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "authMethod",
- "columnName": "authMethod",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "username",
- "columnName": "username",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "password",
- "columnName": "password",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "credential",
- "columnName": "credential",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "jellyfinUserRowId",
- "serverId"
- ]
- },
- "foreignKeys": [
- {
- "table": "seerr_servers",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "serverId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "jellyfinUserRowId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "RememberedTab",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "index",
- "columnName": "index",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "itemId"
- ]
- },
- "indices": [
- {
- "name": "index_RememberedTab_userId_itemId",
- "unique": true,
- "columnNames": [
- "userId",
- "itemId"
- ],
- "orders": [],
- "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- },
- {
- "tableName": "SeriesTrackChoice",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "userId",
- "columnName": "userId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "parentId",
- "columnName": "parentId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "parentType",
- "columnName": "parentType",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "itemId",
- "columnName": "itemId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "language",
- "columnName": "language",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "activation",
- "columnName": "activation",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "trackFlags",
- "columnName": "trackFlags",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "codec",
- "columnName": "codec",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "relativeTrackIndex",
- "columnName": "relativeTrackIndex",
- "affinity": "INTEGER"
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "audioChannels",
- "columnName": "audioChannels",
- "affinity": "INTEGER"
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "userId",
- "parentId",
- "type"
- ]
- },
- "foreignKeys": [
- {
- "table": "users",
- "onDelete": "CASCADE",
- "onUpdate": "CASCADE",
- "columns": [
- "userId"
- ],
- "referencedColumns": [
- "rowId"
- ]
- }
- ]
- }
- ],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f2fe7912f9e3ab83b77029e07488e84')"
- ]
- }
-}
diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml
index e024ca83..8a762a28 100644
--- a/app/src/appstore/AndroidManifest.xml
+++ b/app/src/appstore/AndroidManifest.xml
@@ -3,8 +3,5 @@
xmlns:tools="http://schemas.android.com/tools">
-
diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml
deleted file mode 100644
index 8a762a28..00000000
--- a/app/src/firetv/AndroidManifest.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aad5c5b5..5be4d55a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,7 +15,6 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
-
{
+ val current = serverRepository.current.value
+ val destination =
+ if (current != null) {
+ SetupDestination.UserList(current.server)
+ } else {
+ SetupDestination.ServerList
+ }
+ setupNavigationManager.navigateTo(destination)
+ Timber.e("Error parsing intent: %s", result.message)
+ showToast(context, result.message)
+ return@withLock
+ }
- try {
- val result = handleIntent(intent)
- if (result) {
- Timber.i("appStart was handled by intent")
- return@withLock
+ is IntentResult.Target -> {
+ val current = serverRepository.current.value
+ if (current != null) {
+ Timber.i("Received valid intent, switching to AppContent")
+
+ if (result.addHomeToBackStack) {
+ navigationManager.reloadHome()
+ } else {
+ navigationManager.backStack.clear()
+ }
+ navigationManager.backStack.addAll(result.destinations)
+
+ setupNavigationManager.navigateTo(
+ SetupDestination.AppContent(current),
+ )
+ } else {
+ // This should never happen, but just reset app state if it does
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ Timber.e("Error parsing intent, no user is active")
+ showToast(context, "An error occurred parsing the intent")
+ }
+ return@withLock
+ }
+
+ IntentResult.NoOp,
+ null,
+ -> {
+ // No-op, proceed below
+ }
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
- handleAppStart()
+ val needUpgrade = appUpgradeHandler.needUpgrade()
+ if (needUpgrade) {
+ showToast(
+ context,
+ context.getString(
+ R.string.updated_toast,
+ appUpgradeHandler.currentVersion.toString(),
+ ),
+ )
+ appUpgradeHandler.run()
+ }
+ appUpgradeHandler.copySubfont(false)
+ val prefs =
+ preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
+ val profileProtected =
+ serverRepository.current.value
+ ?.user
+ ?.isProtected == true
+ if (prefs.signInAutomatically && !profileProtected) {
+ val current =
+ serverRepository.restoreSession(
+ prefs.currentServerId?.toUUIDOrNull(),
+ prefs.currentUserId?.toUUIDOrNull(),
+ )
+ if (current != null) {
+ if (current.user.isProtected) {
+ setupNavigationManager.navigateTo(
+ SetupDestination.UserList(
+ current.server,
+ ),
+ )
+ } else {
+ // Restored
+ setupNavigationManager.navigateTo(
+ SetupDestination.AppContent(
+ current,
+ ),
+ )
+ }
+ } else {
+ // Did not restore
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ }
+ } else {
+ setupNavigationManager.navigateTo(SetupDestination.Loading)
+ backdropService.clearBackdrop()
+ val currentServerId = prefs.currentServerId?.toUUIDOrNull()
+ if (currentServerId != null) {
+ val currentServer =
+ serverRepository.serverDao.getServer(currentServerId)?.server
+ if (currentServer != null) {
+ setupNavigationManager.navigateTo(
+ SetupDestination.UserList(
+ currentServer,
+ ),
+ )
+ } else {
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ }
+ } else {
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ }
+ }
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
@@ -488,103 +582,6 @@ class MainActivityViewModel
}
}
}
-
- /**
- * Handle an [Intent], navigating if needed
- *
- * @return true if the intent was handled, false if normal app start should proceed
- */
- @VisibleForTesting
- internal suspend fun handleIntent(intent: Intent?): Boolean {
- val result = intent?.let { intentService.parseIntent(intent) }
- when (result) {
- is IntentResult.Error -> {
- val current = serverRepository.current.value
- val destination =
- if (current != null) {
- SetupDestination.UserList(current.server)
- } else {
- SetupDestination.ServerList
- }
- setupNavigationManager.navigateTo(destination)
- Timber.e("Error parsing intent: %s", result.message)
- showToast(context, result.message)
- return true
- }
-
- is IntentResult.Target -> {
- val current = serverRepository.current.value
- if (current != null) {
- Timber.i("Received valid intent, switching to AppContent")
-
- if (result.addHomeToBackStack) {
- navigationManager.reloadHome()
- } else {
- navigationManager.backStack.clear()
- }
- navigationManager.backStack.addAll(result.destinations)
-
- setupNavigationManager.navigateTo(
- SetupDestination.AppContent(current),
- )
- } else {
- // This should never happen, but just reset app state if it does
- setupNavigationManager.navigateTo(SetupDestination.ServerList)
- Timber.e("Error parsing intent, no user is active")
- showToast(context, "An error occurred parsing the intent")
- }
- return true
- }
-
- IntentResult.NoOp,
- null,
- -> {
- // Not handled
- return false
- }
- }
- }
-
- private suspend fun handleAppUpgrade() {
- val needUpgrade = appUpgradeHandler.needUpgrade()
- if (needUpgrade) {
- showToast(
- context,
- context.getString(
- R.string.updated_toast,
- appUpgradeHandler.currentVersion.toString(),
- ),
- )
- appUpgradeHandler.run()
- }
- appUpgradeHandler.copySubfont(false)
- }
-
- /**
- * Handle starting the app including auto sign in and navigating to the right starting page
- */
- @VisibleForTesting
- internal suspend fun handleAppStart() {
- val signInAutomatically = preferences.data.first().signInAutomatically
- val currentUser = serverRepository.current.value
- if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
- // Hot reload
- Timber.v("Hot reload restoring state")
- setupNavigationManager.navigateTo(
- SetupDestination.AppContent(currentUser),
- )
- } else {
- val restoredSession =
- if (signInAutomatically) {
- Timber.v("Sign in automatically")
- serverRepository.restoreLastSession()
- } else {
- serverRepository.getMostRecentServer()
- }
- Timber.d("Restore last session result=%s", restoredSession)
- setupNavigationManager.navigateTo(restoredSession.destination)
- }
- }
}
private val Destination?.isPlayback: Boolean
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
index 6ec9d4b4..f027bbae 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
@@ -122,7 +122,5 @@ class WholphinApplication :
private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
-
- val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
index 3bc17f3c..57d4a447 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -22,7 +22,6 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
@@ -34,7 +33,9 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@@ -74,10 +75,8 @@ class WholphinDreamService :
lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault {
if (serverRepository.current.value == null) {
- val result = serverRepository.restoreLastSession()
- if (result !is RestoredSession.Success) {
- Timber.w("Could not restore user session")
- }
+ val prefs = preferencesDataStore.data.first()
+ serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
index 45af2d65..6a691723 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
@@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
@@ -49,9 +48,8 @@ import java.util.UUID
SeerrServer::class,
SeerrUser::class,
RememberedTab::class,
- SeriesTrackChoice::class,
],
- version = 41,
+ version = 35,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@@ -70,8 +68,6 @@ import java.util.UUID
AutoMigration(32, 33),
AutoMigration(33, 34),
AutoMigration(34, 35),
- AutoMigration(35, 40),
- AutoMigration(40, 41),
],
)
@TypeConverters(Converters::class)
@@ -91,8 +87,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
-
- abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
}
class Converters {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
index 702ecfdb..c3afbc33 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
@@ -3,12 +3,9 @@ package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
-import com.github.damontecres.wholphin.data.model.RelativeMediaStream
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
@@ -31,7 +28,7 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
- private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
+ private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
/**
@@ -48,79 +45,54 @@ class ItemPlaybackRepository
): ChosenStreams? =
serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
- return getChosenItemFromPlayback(item, itemPlayback, prefs)
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
+ Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
+ return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
}
- suspend fun getChosenItemFromPlayback(
- item: BaseItem,
- itemPlayback: ItemPlayback?,
- prefs: UserPreferences,
- ): ChosenStreams? {
- val audioStc =
- streamChoiceService.getSeriesTrackChoices(
- item.data,
- SeriesTrackChoiceType.AUDIO,
- )
- val subtitleStc =
- streamChoiceService.getSeriesTrackChoices(
- item.data,
- SeriesTrackChoiceType.SUBTITLE,
- )
- return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs)
- }
-
/**
* Get the chosen source & stream for the given item
*
- * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
+ * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
*/
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
- audioStc: List,
- subtitleStc: List,
+ plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): ChosenStreams? {
- Timber.v(
- "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
- item.id,
- itemPlayback != null,
- audioStc.isNotEmpty(),
- subtitleStc.isNotEmpty(),
- )
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
- val audioResult =
+ val audioStream =
streamChoiceService.chooseAudioStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(),
itemPlayback = itemPlayback,
- stc = audioStc,
+ playbackLanguageChoice = plc,
prefs = prefs,
)
val subtitleStream =
streamChoiceService.chooseSubtitleStream(
- audioStreamLang = audioResult.stream?.language,
+ audioStreamLang = audioStream?.language,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
- stc = subtitleStc,
+ playbackLanguageChoice = plc,
prefs = prefs,
)
return ChosenStreams(
itemPlayback = itemPlayback,
- audioStc = audioStc,
- subtitleStc = subtitleStc,
+ plc = plc,
itemId = item.id,
source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
- audioStream = audioResult,
+ audioStream = audioStream,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
@@ -184,36 +156,26 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
- if (seriesId != null || item.data.parentId != null) {
+ if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
if (type == MediaStreamType.AUDIO) {
- val stream =
- RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
+ val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null) {
- streamChoiceService.saveSeriesTrackChoice(
- item.data,
- stream.type,
- stream,
- )
+ streamChoiceService.updateAudio(item.data, stream.language!!)
}
} else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) {
- streamChoiceService.saveDisabledSeriesTrackChoice(
+ streamChoiceService.updateSubtitles(
item.data,
- MediaStreamType.SUBTITLE,
- )
- } else if (trackIndex == TrackIndex.ONLY_FORCED) {
- streamChoiceService.saveOnlyForcedSeriesTrackChoice(
- item.data,
- MediaStreamType.SUBTITLE,
+ subtitleLang = null,
+ subtitlesDisabled = true,
)
} else {
- val stream =
- RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
+ val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
if (stream?.language != null) {
- streamChoiceService.saveSeriesTrackChoice(
+ streamChoiceService.updateSubtitles(
item.data,
- stream.type,
- stream,
+ stream.language!!,
+ subtitlesDisabled = false,
)
}
}
@@ -278,10 +240,9 @@ class ItemPlaybackRepository
*/
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
- val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
- if (stc.isNotEmpty()) {
- Timber.d("Deleting %s", stc)
- seriesTrackChoiceDao.delete(stc)
+ chosenStreams?.plc?.let {
+ Timber.d("Deleting %s", it)
+ playbackLanguageChoiceDao.delete(it)
}
chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it)
@@ -295,12 +256,11 @@ class ItemPlaybackRepository
*/
data class ChosenStreams(
val itemPlayback: ItemPlayback?,
- val audioStc: List,
- val subtitleStc: List,
+ val plc: PlaybackLanguageChoice?,
val itemId: UUID,
val source: MediaSourceInfo,
val videoStream: MediaStream?,
- val audioStream: StreamChoiceResult,
- val subtitleStream: StreamChoiceResult,
+ val audioStream: MediaStream?,
+ val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt
deleted file mode 100644
index 5139fb73..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt
+++ /dev/null
@@ -1,175 +0,0 @@
-package com.github.damontecres.wholphin.data
-
-import android.content.Context
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.github.damontecres.wholphin.data.MostRecentServer.None
-import com.github.damontecres.wholphin.data.MostRecentServer.Server
-import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser
-import com.github.damontecres.wholphin.data.model.JellyfinServer
-import com.github.damontecres.wholphin.services.SetupDestination
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
-import com.github.damontecres.wholphin.ui.toServerString
-import org.jellyfin.sdk.model.serializer.toUUIDOrNull
-import java.util.UUID
-
-/**
- * Represents the status of the most recently used server
- *
- * This can be saved/retrieved from a [MostRecentServerProvider]
- */
-sealed interface MostRecentServer {
- /**
- * There is no recently used server
- */
- data object None : MostRecentServer
-
- /**
- * The most recently used server without a recent user
- */
- data class Server(
- val serverId: UUID,
- val serverUrl: String,
- ) : MostRecentServer
-
- /**
- * Most recently used server and user
- */
- data class ServerAndUser(
- val serverId: UUID,
- val serverUrl: String,
- val userId: UUID,
- val accessToken: String,
- ) : MostRecentServer {
- override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)"
- }
-
- companion object {
- fun from(current: CurrentUser?): MostRecentServer =
- if (current != null && current.user.accessToken.isNotNullOrBlank()) {
- ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken)
- } else if (current != null) {
- Server(current.server.id, current.server.url)
- } else {
- None
- }
- }
-}
-
-interface MostRecentServerProvider {
- /**
- * Get the [MostRecentServer] from this provider
- */
- fun get(): MostRecentServer
-
- /**
- * Save the [CurrentUser] as the most recent server
- */
- suspend fun save(current: CurrentUser)
-
- /**
- * Clear the most recent server and user
- */
- suspend fun clear()
-
- /**
- * CLear the most recent user, leaving the server
- */
- suspend fun clearUser()
-}
-
-/**
- * A [MostRecentServerProvider] that stores in [SharedPreferences]
- */
-class MostRecentServerSharedPreferences(
- context: Context,
-) : MostRecentServerProvider {
- private val prefs = getServerSharedPreferences(context)
-
- override fun get(): MostRecentServer {
- val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
- val serverUrl = prefs.getString(SERVER_URL_KEY, null)
- val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull()
- val accessToken = prefs.getString(USER_TOKEN_KEY, null)
- return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) {
- ServerAndUser(serverId, serverUrl, userId, accessToken)
- } else if (serverId != null && serverUrl.isNotNullOrBlank()) {
- Server(serverId, serverUrl)
- } else {
- None
- }
- }
-
- override suspend fun save(current: CurrentUser) {
- prefs.edit(true) {
- putString(SERVER_ID_KEY, current.server.id.toServerString())
- putString(SERVER_URL_KEY, current.server.url)
- putString(USER_ID_KEY, current.user.id.toServerString())
- putString(USER_TOKEN_KEY, current.user.accessToken)
- }
- }
-
- override suspend fun clear() {
- prefs.edit(true) {
- remove(SERVER_ID_KEY)
- remove(SERVER_URL_KEY)
- remove(USER_ID_KEY)
- remove(USER_TOKEN_KEY)
- }
- }
-
- override suspend fun clearUser() {
- prefs.edit(true) {
- remove(USER_ID_KEY)
- remove(USER_TOKEN_KEY)
- }
- }
-
- private fun getServerSharedPreferences(context: Context): SharedPreferences =
- context.getSharedPreferences(
- "${context.packageName}_server",
- Context.MODE_PRIVATE,
- )
-
- companion object {
- private const val SERVER_ID_KEY = "current.server.id"
- private const val SERVER_URL_KEY = "current.server.url"
- private const val USER_ID_KEY = "current.user.id"
- private const val USER_TOKEN_KEY = "current.user.accessToken"
- }
-}
-
-/**
- * The result of restoring a session
- */
-sealed interface RestoredSession {
- val destination: SetupDestination
-
- /**
- * Session could not be restored
- */
- data object None : RestoredSession {
- override val destination: SetupDestination
- get() = SetupDestination.ServerList
- }
-
- /**
- * Only the server should be restored, so a user still need to be chosen
- */
- data class ServerOnly(
- val server: JellyfinServer,
- ) : RestoredSession {
- override val destination: SetupDestination
- get() = SetupDestination.UserList(server)
- }
-
- /**
- * Session was restored successfully
- */
- data class Success(
- val currentUser: CurrentUser,
- ) : RestoredSession {
- override val destination: SetupDestination
- get() = SetupDestination.AppContent(currentUser)
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
index 06835381..5f8c16e6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
@@ -8,27 +8,17 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID
-@Deprecated("Use SeriesTrackChoiceDao")
@Dao
interface PlaybackLanguageChoiceDao {
- @Deprecated("Use SeriesTrackChoiceDao")
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get(
userId: Int,
seriesId: UUID,
): PlaybackLanguageChoice?
- @Deprecated("Use SeriesTrackChoiceDao")
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
- @Deprecated("Use SeriesTrackChoiceDao")
@Delete
fun delete(plc: PlaybackLanguageChoice)
-
- @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
- suspend fun getAll(
- limit: Int,
- offset: Int,
- ): List
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt
deleted file mode 100644
index b3126723..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.github.damontecres.wholphin.data
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
-import java.util.UUID
-
-@Dao
-interface SeriesTrackChoiceDao {
- @Query(
- "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC",
- )
- suspend fun get(
- userId: Int,
- seasonId: UUID,
- seriesId: UUID,
- type: SeriesTrackChoiceType,
- ): List
-
- @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId")
- suspend fun getBySeasonId(
- userId: Int,
- seasonId: UUID,
- type: SeriesTrackChoiceType,
- ): List
-
- @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId")
- suspend fun getBySeriesId(
- userId: Int,
- seriesId: UUID,
- type: SeriesTrackChoiceType,
- ): List
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun save(stc: SeriesTrackChoice): Long
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun save(stc: List): List
-
- @Delete
- suspend fun delete(stc: List)
-
- @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId")
- suspend fun getAll(userId: Int): List
-
- @Query("SELECT count(*) from SeriesTrackChoice")
- suspend fun count(): Int
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
index 2c24c8f3..5deba4c3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
@@ -1,10 +1,15 @@
package com.github.damontecres.wholphin.data
import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.ServerUserConfig
+import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
+import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
@@ -44,8 +49,8 @@ class ServerRepository
val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
+ val userPreferencesDataStore: DataStore,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
- private val mostRecentServerProvider: MostRecentServerProvider,
) {
private var _current = MutableStateFlow(null)
val current: StateFlow = _current
@@ -72,8 +77,6 @@ class ServerRepository
}
}
- val serverPluginInstalled = MutableStateFlow(false)
-
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
*
@@ -85,11 +88,10 @@ class ServerRepository
}
apiClient.update(baseUrl = server.url, accessToken = null)
_current.value = null
- mostRecentServerProvider.clearUser()
}
/**
- * Saves the server & user to the app database and updates the [ApiClient] to use this server & user
+ * Saves the server & User to the app database and updates the [ApiClient] to use this server & user
*/
suspend fun changeUser(
server: JellyfinServer,
@@ -117,101 +119,70 @@ class ServerRepository
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
-
+ userPreferencesDataStore.updateData {
+ it
+ .toBuilder()
+ .apply {
+ currentServerId = updatedServer.id.toServerString()
+ currentUserId = updatedUser.id.toServerString()
+ }.build()
+ }
val currentUser = CurrentUser(updatedServer, updatedUser)
- mostRecentServerProvider.save(currentUser)
- _current.value = currentUser
- _currentUserDto.value = ServerUserConfig(userDto)
+ withContext(WholphinDispatchers.Main) {
+ _current.value = currentUser
+ _currentUserDto.value = ServerUserConfig(userDto)
+ }
+ getServerSharedPreferences(context).edit(true) {
+ putString(SERVER_URL_KEY, updatedServer.url)
+ putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
+ }
return@withContext currentUser
}
/**
- * Try to restore the most recent session
+ * Restores a session for the given server & user such as when the app reopens
*
- * Does not check if auto sign-in is enabled, but does check if the user profile is protected
+ * If user has a PIN, this returns false
*/
- suspend fun restoreLastSession(): RestoredSession {
- val recentServer = mostRecentServerProvider.get()
- Timber.d("restoreLastSession: recentServer=%s", recentServer)
- val result =
- when (recentServer) {
- MostRecentServer.None -> {
- RestoredSession.None
- }
-
- is MostRecentServer.Server -> {
- val server = serverDao.getServer(recentServer.serverId)
- if (server != null) {
- RestoredSession.ServerOnly(server.server)
- } else {
- RestoredSession.None
- }
- }
-
- is MostRecentServer.ServerAndUser -> {
- val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
- if (currentUser != null) {
- if (currentUser.user.isProtected) {
- RestoredSession.ServerOnly(currentUser.server)
- } else {
- RestoredSession.Success(currentUser)
- }
- } else {
- getMostRecentServerInternal(recentServer)
- }
- }
- }
- return result
- }
-
- /**
- * Get the most recently used server, if any
- */
- suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
-
- suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
- val recentServer = recentServer ?: mostRecentServerProvider.get()
- val serverId =
- when (recentServer) {
- MostRecentServer.None -> null
- is MostRecentServer.Server -> recentServer.serverId
- is MostRecentServer.ServerAndUser -> recentServer.serverId
- }
- val server = serverId?.let { serverDao.getServer(serverId)?.server }
- return if (server != null) {
- RestoredSession.ServerOnly(server)
- } else {
- RestoredSession.None
- }
- }
-
- /**
- * Try to change to the given server & user. If the user's profile is protected, returns null
- *
- * @return the server/user or null if the session could not be restored
- */
- suspend fun tryChangeUser(
+ suspend fun restoreSession(
serverId: UUID?,
userId: UUID?,
- ): CurrentUser? =
- withContext(ioDispatcher) {
- return@withContext if (serverId == null || userId == null) {
- _current.value = null
- null
+ ): CurrentUser? {
+ if (serverId == null || userId == null) {
+ _current.value = null
+ return null
+ }
+ val serverAndUsers =
+ withContext(ioDispatcher) {
+ serverDao.getServer(serverId)
+ }
+ if (serverAndUsers != null) {
+ val current = _current.value
+ if (current != null && current.server.id == serverId && current.user.id == userId) {
+ Timber.v("Restoring session for current user, so shortcut")
+ apiClient.update(
+ baseUrl = current.server.url,
+ accessToken = current.user.accessToken,
+ )
+ return current
} else {
- val serverAndUsers = serverDao.getServer(serverId)
- if (serverAndUsers != null) {
- val user = serverAndUsers.users.firstOrNull { it.id == userId }
- if (user != null && !user.isProtected) {
- changeUser(serverAndUsers.server, user)
- } else {
- null
- }
- } else {
- null
+ val user = serverAndUsers.users.firstOrNull { it.id == userId }
+ if (user != null) {
+ return changeUser(serverAndUsers.server, user)
}
}
}
+ return null
+ }
+
+ suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
+ withContext(ioDispatcher) {
+ serverId?.let { serverDao.getServer(serverId)?.server }
+ }
+
+ fun closeSession() {
+ _current.value = null
+ }
/**
* Given a successful [AuthenticationResult], switch to the user that just authenticated
@@ -273,7 +244,13 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
- mostRecentServerProvider.clearUser()
+ userPreferencesDataStore.updateData {
+ it
+ .toBuilder()
+ .apply {
+ currentUserId = ""
+ }.build()
+ }
apiClient.update(accessToken = null)
}
withContext(ioDispatcher) {
@@ -286,7 +263,14 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
- mostRecentServerProvider.clear()
+ userPreferencesDataStore.updateData {
+ it
+ .toBuilder()
+ .apply {
+ currentServerId = ""
+ currentUserId = ""
+ }.build()
+ }
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(ioDispatcher) {
@@ -295,7 +279,14 @@ class ServerRepository
}
suspend fun switchServerOrUser() {
- mostRecentServerProvider.clear()
+ userPreferencesDataStore.updateData {
+ it
+ .toBuilder()
+ .apply {
+ currentServerId = ""
+ currentUserId = ""
+ }.build()
+ }
}
suspend fun updateUserAuth(
@@ -344,6 +335,17 @@ class ServerRepository
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
}
}
+
+ companion object {
+ fun getServerSharedPreferences(context: Context): SharedPreferences =
+ context.getSharedPreferences(
+ "${context.packageName}_server",
+ Context.MODE_PRIVATE,
+ )
+
+ const val SERVER_URL_KEY = "current.server"
+ const val ACCESS_TOKEN_KEY = "current.accessToken"
+ }
}
@Serializable
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index 256ccd65..4b38d398 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -66,7 +66,6 @@ data class BaseItem(
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
- BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ")
else -> data.productionYear?.toString()
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
index 91228969..3746092b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
@@ -9,7 +9,6 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType
-import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@@ -156,22 +155,6 @@ data class GetItemsFilter(
},
)
- fun applyTo(req: GetAlbumArtistsRequest) =
- req.copy(
- minCommunityRating = minCommunityRating,
- isFavorite = favorite,
- genreIds = genres,
- personIds = persons,
- studioIds = studios,
- tags = tags,
- officialRatings = officialRatings,
- years =
- buildSet {
- years?.letNotEmpty(::addAll)
- decades?.forEach { addAll(it..<(it + 10)) }
- },
- )
-
/**
* Merge another [GetItemsFilter] onto this one, replacing only unset values
*/
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
index 39b82bc8..4914248f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -1,7 +1,4 @@
-@file:UseSerializers(
- UUIDSerializer::class,
- DateTimeSerializer::class,
-)
+@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
@@ -10,15 +7,11 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ItemSortBy
-import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
-import org.jellyfin.sdk.model.serializer.DateTimeSerializer
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@@ -181,45 +174,19 @@ sealed interface HomeRowConfig {
}
/**
- * An arbitrary [HomeGetItemsFilter] allowing to query for most anything
+ * An arbitrary [GetItemsRequest] allowing to query for anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
- val filter: HomeGetItemsFilter,
+ val getItems: GetItemsRequest,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
-
- /**
- * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult.
- * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing
- * specific knowledge of them.
- *
- * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin
- * config is persisted as XML, which doesn't support IDictionary.
- */
- @Serializable
- @SerialName("CustomEndpoint")
- data class CustomEndpoint(
- val endpoint: String,
- val title: String,
- val headers: List? = null,
- val query: List? = null,
- override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
- ) : HomeRowConfig {
- override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions)
- }
}
-@Serializable
-data class KeyValueEntry(
- val key: String,
- val value: String,
-)
-
/**
* Root class for home page settings
*
@@ -274,54 +241,3 @@ data class HomeRowViewOptions(
)
}
}
-
-/**
- * Subset of [GetItemsFilter]
- */
-@Serializable
-data class HomeGetItemsFilter(
- val sortBy: List? = null,
- val sortOrder: List? = null,
- val ids: List? = null,
- val minCommunityRating: Double? = null,
- val minCriticRating: Double? = null,
- val recursive: Boolean? = null,
- val searchTerm: String? = null,
- val parentId: UUID? = null,
- val includeItemTypes: List? = null,
- val isFavorite: Boolean? = null,
- val isPlayed: Boolean? = null,
- val officialRatings: Collection? = null,
- val tags: List? = null,
- val years: List? = null,
- val personIds: List? = null,
- val minOfficialRating: String? = null,
- val hasOfficialRating: Boolean? = null,
- val studioIds: List? = null,
- val genreIds: List? = null,
- val minPremiereDate: LocalDateTime? = null,
- val maxPremiereDate: LocalDateTime? = null,
-) {
- fun asGetItemsRequest() =
- GetItemsRequest(
- sortBy = sortBy,
- sortOrder = sortOrder,
- ids = ids,
- minCommunityRating = minCommunityRating,
- minCriticRating = minCriticRating,
- recursive = recursive,
- searchTerm = searchTerm,
- parentId = parentId,
- includeItemTypes = includeItemTypes,
- isFavorite = isFavorite,
- isPlayed = isPlayed,
- officialRatings = officialRatings,
- tags = tags,
- years = years,
- personIds = personIds,
- minOfficialRating = minOfficialRating,
- hasOfficialRating = hasOfficialRating,
- studioIds = studioIds,
- genreIds = genreIds,
- )
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
index 6c5a2a58..4c54311c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
@@ -13,7 +13,6 @@ import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserProfileSettings
-import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@@ -69,7 +68,6 @@ data class JellyfinUser(
val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null,
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
- @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
@@ -79,17 +77,9 @@ data class JellyfinUser(
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
- "appPreferences=$appPreferences, config=$config"
-
- fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
+ "appPreferences=$appPreferences"
}
-@Serializable
-data class JellyfinUserConfig(
- @ColumnInfo(defaultValue = "UNSET")
- val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
-)
-
/**
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
*/
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
index a20a2dcd..4e639009 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
@@ -35,7 +35,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
+ imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false,
)
@@ -50,7 +50,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
+ imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
index f5507a50..fb54dfcd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
@@ -10,14 +10,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
/**
- * DEPRECATED
- *
* Stores the language choices for a series so they can be applied automatically to other episodes
* without the user needing to explicitly choose the tracks
- *
- * @see SeriesTrackChoice
*/
-@Deprecated("Use SeriesTrackChoice")
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt
deleted file mode 100644
index cd2fece7..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.github.damontecres.wholphin.data.model
-
-import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
-import org.jellyfin.sdk.model.api.MediaStream
-import org.jellyfin.sdk.model.api.MediaStreamType
-
-/**
- * A [MediaStream] with its relative track index, its position within a specific stream type
- */
-data class RelativeMediaStream(
- val relativeIndex: Int,
- val stream: MediaStream,
-) {
- val type: MediaStreamType get() = stream.type
- val language: String? get() = stream.language
- val isForced: Boolean get() = stream.isForced
- val isExternal: Boolean get() = stream.isExternal
- val isDefault: Boolean get() = stream.isDefault
- val isHearingImpaired: Boolean get() = stream.isHearingImpaired
-
- companion object {
- fun getRelativeIndex(
- index: Int,
- streams: List,
- ): Int? {
- val stream = streams.firstOrNull { it.index == index } ?: return null
- return getRelativeIndex(stream, streams)
- }
-
- fun getRelativeIndex(
- stream: MediaStream,
- streams: List,
- ): Int? =
- streams
- .filter { it.type == stream.type && it.isExternal == stream.isExternal }
- .sortedBy { it.index }
- .indexOfFirstOrNull { it.index == stream.index }
-
- fun from(
- index: Int,
- streams: List,
- ): RelativeMediaStream? {
- val stream = streams.firstOrNull { it.index == index } ?: return null
- val relativeIndex = getRelativeIndex(stream, streams) ?: return null
- return RelativeMediaStream(relativeIndex, stream)
- }
- }
-}
-
-/**
- * Maps a list of [MediaStream] into [RelativeMediaStream]
- */
-fun List.asRelative(): List {
- val map = this.groupBy { Pair(it.type, it.isExternal) }
- return map.values
- .flatMap { typedStreams ->
- typedStreams.sortedBy { it.index }.mapIndexed { index, stream ->
- RelativeMediaStream(index, stream)
- }
- }.sortedBy { it.stream.index }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
index 918384d4..aafdebf7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
@@ -54,11 +54,3 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions)
}
-
-val SeerrUserConfig?.canRequestMovies: Boolean
- get() =
- hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
-
-val SeerrUserConfig?.canRequestTv: Boolean
- get() =
- hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt
deleted file mode 100644
index 1ff7212b..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.github.damontecres.wholphin.data.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-sealed interface SeerrPluginLogin {
- @SerialName("None")
- @Serializable
- data object None : SeerrPluginLogin
-
- @SerialName("ApiKey")
- @Serializable
- data class ApiKey(
- val apiKey: String,
- ) : SeerrPluginLogin
-
- @SerialName("Local")
- @Serializable
- data class Local(
- val local: SeerrPluginLocalLogin,
- ) : SeerrPluginLogin
-}
-
-@Serializable
-data class SeerrPluginLocalLogin(
- val username: String,
- val password: String,
-)
-
-@Serializable
-data class SeerrPluginSettings(
- val version: Int = 1,
- val serverUrl: String? = null,
- val login: SeerrPluginLogin? = null,
-)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt
deleted file mode 100644
index 5dd313d6..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt
+++ /dev/null
@@ -1,199 +0,0 @@
-@file:UseSerializers(UUIDSerializer::class)
-
-package com.github.damontecres.wholphin.data.model
-
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import com.github.damontecres.wholphin.services.isSigns
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.UseSerializers
-import org.jellyfin.sdk.model.api.MediaStream
-import org.jellyfin.sdk.model.api.MediaStreamType
-import org.jellyfin.sdk.model.serializer.UUIDSerializer
-import java.util.UUID
-
-/**
- * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks
- *
- * Additional information about the chosen track is stored to compare against tracks in other media
- */
-@Entity(
- foreignKeys = [
- ForeignKey(
- entity = JellyfinUser::class,
- parentColumns = arrayOf("rowId"),
- childColumns = arrayOf("userId"),
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- ),
- ],
- primaryKeys = ["userId", "parentId", "type"],
-)
-@Serializable
-data class SeriesTrackChoice(
- val userId: Int,
- /**
- * Season or Series
- */
- val parentId: UUID,
- /**
- * Type of track, ie audio or subtitle
- */
- val type: SeriesTrackChoiceType,
- /**
- * Origin of the [parentId]
- */
- val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN,
- val itemId: UUID? = null,
- val language: String? = null,
- val activation: ActivationFlag = ActivationFlag.ACTIVATED,
- /**
- * Flags for the track such as external or forced
- *
- * @see [TrackFlag]
- */
- val trackFlags: Int = 0,
- val codec: String? = null,
- /**
- * The track index based on its position within all streams of the same type
- *
- * @see [RelativeMediaStream]
- */
- val relativeTrackIndex: Int? = null,
- val title: String? = null,
- val audioChannels: Int? = null,
-) {
- constructor(
- userId: Int,
- itemId: UUID,
- parentId: UUID,
- type: SeriesTrackChoiceType,
- parentType: TrackChoiceParentType,
- stream: RelativeMediaStream?,
- activation: ActivationFlag,
- ) : this(
- userId = userId,
- parentId = parentId,
- parentType = parentType,
- type = type,
- itemId = itemId,
- language = stream?.language,
- activation = activation,
- trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0,
- codec = stream?.stream?.codec,
- relativeTrackIndex = stream?.relativeIndex,
- title = stream?.stream?.title,
- audioChannels = stream?.stream?.channels,
- )
-
- companion object {
- /**
- * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s
- */
- fun from(plc: PlaybackLanguageChoice): List =
- buildList {
- if (plc.subtitleLanguage != null) {
- add(
- SeriesTrackChoice(
- userId = plc.userId,
- parentId = plc.seriesId,
- parentType = TrackChoiceParentType.DEPRECATED,
- type = SeriesTrackChoiceType.SUBTITLE,
- itemId = plc.itemId,
- language = plc.subtitleLanguage,
- activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- ),
- )
- }
- if (plc.audioLanguage != null) {
- add(
- SeriesTrackChoice(
- userId = plc.userId,
- parentId = plc.seriesId,
- parentType = TrackChoiceParentType.DEPRECATED,
- type = SeriesTrackChoiceType.AUDIO,
- itemId = plc.itemId,
- language = plc.audioLanguage,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- ),
- )
- }
- }
- }
-}
-
-enum class SeriesTrackChoiceType(
- val mediaStreamType: MediaStreamType,
-) {
- AUDIO(MediaStreamType.AUDIO),
- SUBTITLE(MediaStreamType.SUBTITLE),
-}
-
-/**
- * Flags for a track such as forced or defect
- */
-enum class TrackFlag(
- val flag: Int,
- val hasFlag: (MediaStream) -> Boolean,
-) {
- DEFAULT(flag = 1, hasFlag = { it.isDefault }),
- FORCED(flag = 2, hasFlag = { it.isForced }),
- SDH(flag = 4, hasFlag = { it.isHearingImpaired }),
- EXTERNAL(flag = 8, hasFlag = { it.isExternal }),
- SIGNS(flag = 16, hasFlag = { isSigns(it) }),
- ;
-
- fun within(flag: Int) = flag and this.flag == this.flag
-
- companion object {
- fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags)
-
- fun Iterable.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag }
-
- fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag()
- }
-}
-
-/**
- * How was the track activated
- */
-enum class ActivationFlag {
- ACTIVATED,
- DISABLED,
- ONLY_FORCED,
-}
-
-/**
- * Where the [SeriesTrackChoice.parentId] comes from
- */
-enum class TrackChoiceParentType {
- /**
- * Parent ID is from the series
- */
- SERIES,
-
- /**
- * Parent ID is from the season
- */
- SEASON,
-
- /**
- * Parent ID is from the deprecated [PlaybackLanguageChoice]
- */
- DEPRECATED,
-
- /**
- * Parent ID origin is unknown
- */
- UNKNOWN,
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 63761d99..188b7020 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -9,7 +9,6 @@ import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker
-import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
@@ -346,7 +345,7 @@ sealed interface AppPreference {
const val MEGA_BIT = 1024 * 1024L
const val DEFAULT_BITRATE = 100 * MEGA_BIT
- val bitrateValues =
+ private val bitrateValues =
listOf(
500 * 1024L,
750 * 1024L,
@@ -379,7 +378,11 @@ sealed interface AppPreference {
summarizer = { value ->
if (value != null) {
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
- formatMaxBitrate(v)
+ if (v < MEGA_BIT) {
+ "${v / 1024} kbps"
+ } else {
+ "${v / MEGA_BIT} Mbps"
+ }
} else {
null
}
@@ -453,69 +456,15 @@ sealed interface AppPreference {
summaryOff = R.string.disabled,
)
- val Hdr10Override =
- AppChoicePreference(
- title = R.string.hdr10_override,
- defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
- getter = { it.playbackPreferences.overrides.hdr10Override },
+ val DirectPlayDoviProfile7 =
+ AppSwitchPreference(
+ title = R.string.force_dovi_profile_7,
+ defaultValue = false,
+ getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
setter = { prefs, value ->
- prefs.updatePlaybackOverrides { hdr10Override = value }
+ prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value }
},
- displayValues = R.array.hdr_override_modes,
- indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
- valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
- )
-
- val Hdr10PlusOverride =
- AppChoicePreference(
- title = R.string.hdr10_plus_override,
- defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
- getter = { it.playbackPreferences.overrides.hdr10PlusOverride },
- setter = { prefs, value ->
- prefs.updatePlaybackOverrides { hdr10PlusOverride = value }
- },
- displayValues = R.array.hdr_override_modes,
- indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
- valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
- )
-
- val DoviProfile5Override =
- AppChoicePreference(
- title = R.string.dovi_profile_5,
- defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
- getter = { it.playbackPreferences.overrides.doviProfile5Override },
- setter = { prefs, value ->
- prefs.updatePlaybackOverrides { doviProfile5Override = value }
- },
- displayValues = R.array.hdr_override_modes,
- indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
- valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
- )
-
- val DoviProfile7Override =
- AppChoicePreference(
- title = R.string.dovi_profile_7,
- defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
- getter = { it.playbackPreferences.overrides.doviProfile7Override },
- setter = { prefs, value ->
- prefs.updatePlaybackOverrides { doviProfile7Override = value }
- },
- displayValues = R.array.hdr_override_modes,
- indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
- valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
- )
-
- val DoviProfile8Override =
- AppChoicePreference(
- title = R.string.dovi_profile_8,
- defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
- getter = { it.playbackPreferences.overrides.doviProfile8Override },
- setter = { prefs, value ->
- prefs.updatePlaybackOverrides { doviProfile8Override = value }
- },
- displayValues = R.array.hdr_override_modes,
- indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
- valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
+ summary = R.string.force_dovi_profile_7_summary,
)
val DecodeAv1 =
@@ -970,12 +919,6 @@ sealed interface AppPreference {
destination = Destination.Settings(PreferenceScreenOption.MPV),
)
- val HdrFormatSettings =
- AppDestinationPreference(
- title = R.string.hdr_formats,
- destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
- )
-
val MpvHardwareDecoding =
AppSwitchPreference(
title = R.string.mpv_hardware_decoding,
@@ -1240,17 +1183,8 @@ private val ExoPlayerSettings =
AppPreference.Ac3Supported,
AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs,
+ AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
- AppPreference.HdrFormatSettings,
- )
-
-private val HdrFormatSettings =
- listOf(
- AppPreference.Hdr10Override,
- AppPreference.Hdr10PlusOverride,
- AppPreference.DoviProfile5Override,
- AppPreference.DoviProfile7Override,
- AppPreference.DoviProfile8Override,
)
val ExoPlayerPreferences =
@@ -1261,14 +1195,6 @@ val ExoPlayerPreferences =
),
)
-val HdrFormatPreferences =
- listOf(
- PreferenceGroup(
- title = R.string.hdr_formats,
- preferences = HdrFormatSettings,
- ),
- )
-
private val MpvSettings =
listOf(
AppPreference.MpvHardwareDecoding,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index 85f8aade..cb47ca94 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -186,8 +186,6 @@ class AppPreferencesSerializer
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
preferDolbyVisionOverHdr10Plus =
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
- convertDolbyVisionProfile7 =
- ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
}.build()
}.build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
index 46c75636..0647a900 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
@@ -48,17 +48,6 @@ object ExperimentalPreference {
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
)
- val ConvertDolbyVisionProfile7 =
- AppSwitchPreference(
- title = R.string.convert_dovi_profile_7,
- defaultValue = false,
- getter = { it.experimentalPreferences.convertDolbyVisionProfile7 },
- setter = { prefs, value ->
- prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value }
- },
- summary = R.string.convert_dovi_profile_7_summary,
- )
-
val PreferAc3ForSurround =
AppSwitchPreference(
title = R.string.prefer_ac3_for_surround,
@@ -104,7 +93,6 @@ val experimentalPreferences =
listOf(
ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
- ExperimentalPreference.ConvertDolbyVisionProfile7,
ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload,
),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt
deleted file mode 100644
index df0b6823..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.github.damontecres.wholphin.preferences
-
-import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
-
-// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt
-
-/**
- * HDR formats that the user can override in the device profile.
- */
-enum class HdrFormat(
- val getter: (AppPreferences) -> HdrOverrideMode,
- val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set,
-) {
- HDR10(
- getter = { it.playbackPreferences.overrides.hdr10Override },
- rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) },
- ),
- HDR10_PLUS(
- getter = { it.playbackPreferences.overrides.hdr10PlusOverride },
- rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) },
- ),
- DOVI_PROFILE_5(
- getter = { it.playbackPreferences.overrides.doviProfile5Override },
- rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) },
- ),
- DOVI_PROFILE_7(
- getter = { it.playbackPreferences.overrides.doviProfile7Override },
- rangeTypeNames = { jellyfinTenEleven ->
- if (jellyfinTenEleven) {
- setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
- } else {
- emptySet()
- }
- },
- ),
- DOVI_PROFILE_8(
- getter = { it.playbackPreferences.overrides.doviProfile8Override },
- rangeTypeNames = { jellyfinTenEleven ->
- buildSet {
- add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
- if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
- }
- },
- ),
-}
-
-/**
- * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode]
- */
-fun AppPreferences.getHdrRangeTypesFor(
- mode: HdrOverrideMode,
- jellyfinTenEleven: Boolean,
-): Set =
- HdrFormat.entries
- .filter { it.getter(this) == mode }
- .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index 17312378..4910110a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -6,20 +6,14 @@ import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
-import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
-import com.github.damontecres.wholphin.data.MostRecentServerProvider
-import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
-import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
@@ -42,7 +36,6 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@@ -63,9 +56,6 @@ class AppUpgradeHandler
private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
- private val mostRecentServerProvider: MostRecentServerProvider,
- private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
- private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@@ -452,59 +442,5 @@ class AppUpgradeHandler
}
}
}
-
- if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
- try {
- val prefs = appPreferences.data.firstOrNull()
- val serverId = prefs?.currentServerId?.toUUIDOrNull()
- val userId = prefs?.currentUserId?.toUUIDOrNull()
- if (serverId != null && userId != null) {
- val server = serverDao.getServer(serverId)
- val user = server?.users?.firstOrNull { it.id == userId }
- if (server != null && user != null) {
- Timber.i("Migrating most recent server")
- mostRecentServerProvider.save(CurrentUser(server.server, user))
- }
- }
- } catch (ex: Exception) {
- Timber.e(ex, "Error during migration for most recent server")
- }
- appPreferences.updateData {
- it.update {
- currentServerId = ""
- currentUserId = ""
- }
- }
- }
-
- if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) {
- // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward
- appPreferences.updateData {
- if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) {
- it.updatePlaybackOverrides {
- doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
- directPlayDolbyVisionEL = false
- }
- } else {
- it
- }
- }
- }
-
- if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
- val count = seriesTrackChoiceDao.count()
- if (count == 0) {
- var offset = 0
- while (true) {
- val plcs = playbackLanguageChoiceDao.getAll(100, offset)
- if (plcs.isEmpty()) break
- offset += plcs.size
-
- val stcs = plcs.flatMap(SeriesTrackChoice::from)
- Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
- seriesTrackChoiceDao.save(stcs)
- }
- }
- }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
index 13ef5f70..b9b15f97 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
@@ -4,12 +4,8 @@ import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
-import com.github.damontecres.wholphin.preferences.HdrFormat
-import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled
-import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
-import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@@ -65,22 +61,9 @@ class DeviceProfileService
downMixAudio = newConfig.overrides.downmixStereo,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs,
+ dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
- forceEnabledHdr =
- buildSet {
- appPrefs
- .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
- .let(::addAll)
- // A profile 7 track the player converts to 8.1 is one the server need not transcode
- if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
- HdrFormat.DOVI_PROFILE_7
- .rangeTypeNames(newConfig.jellyfinTenEleven)
- .let(::addAll)
- }
- },
- forceDisabledHdr =
- appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
jellyfinTenEleven = newConfig.jellyfinTenEleven,
maxResolution = newConfig.overrides.maxResolution,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt
deleted file mode 100644
index fe462b98..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt
+++ /dev/null
@@ -1,765 +0,0 @@
-package com.github.damontecres.wholphin.services
-
-import androidx.core.net.toUri
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.HomeRowConfig
-import com.github.damontecres.wholphin.data.model.ServerUserConfig
-import com.github.damontecres.wholphin.data.model.createGenreDestination
-import com.github.damontecres.wholphin.data.model.createStudioDestination
-import com.github.damontecres.wholphin.preferences.HomePagePreferences
-import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
-import com.github.damontecres.wholphin.ui.HomeItemFields
-import com.github.damontecres.wholphin.ui.ProgramItemFields
-import com.github.damontecres.wholphin.ui.components.getGenreImageMap
-import com.github.damontecres.wholphin.ui.formatTypeName
-import com.github.damontecres.wholphin.ui.main.settings.Library
-import com.github.damontecres.wholphin.ui.playback.getTypeFor
-import com.github.damontecres.wholphin.ui.toBaseItems
-import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
-import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
-import com.github.damontecres.wholphin.ui.util.ResStringProvider
-import com.github.damontecres.wholphin.ui.util.StringStringProvider
-import com.github.damontecres.wholphin.util.ApiRequestPager
-import com.github.damontecres.wholphin.util.GetArtistsHandler
-import com.github.damontecres.wholphin.util.GetGenresRequestHandler
-import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
-import com.github.damontecres.wholphin.util.GetPersonsHandler
-import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
-import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
-import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
-import com.github.damontecres.wholphin.util.HomeRowLoadingState
-import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.json.decodeFromStream
-import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.exception.InvalidStatusException
-import org.jellyfin.sdk.api.client.extensions.liveTvApi
-import org.jellyfin.sdk.api.client.extensions.userLibraryApi
-import org.jellyfin.sdk.model.DateTime
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.CollectionType
-import org.jellyfin.sdk.model.api.GetProgramsDto
-import org.jellyfin.sdk.model.api.ImageType
-import org.jellyfin.sdk.model.api.ItemFields
-import org.jellyfin.sdk.model.api.ItemSortBy
-import org.jellyfin.sdk.model.api.SortOrder
-import org.jellyfin.sdk.model.api.request.GetArtistsRequest
-import org.jellyfin.sdk.model.api.request.GetGenresRequest
-import org.jellyfin.sdk.model.api.request.GetItemsRequest
-import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
-import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
-import org.jellyfin.sdk.model.api.request.GetPersonsRequest
-import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
-import org.jellyfin.sdk.model.api.request.GetStudiosRequest
-import timber.log.Timber
-import java.time.LocalDateTime
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@OptIn(ExperimentalSerializationApi::class)
-@Singleton
-class HomeDataService
- @Inject
- constructor(
- private val api: ApiClient,
- @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
- private val serverRepository: ServerRepository,
- private val latestNextUpService: LatestNextUpService,
- private val imageUrlService: ImageUrlService,
- private val suggestionService: SuggestionService,
- ) {
- /**
- * Fetch the data from the server for a given [HomeRowConfig]
- */
- suspend fun fetchDataForRow(
- row: HomeRowConfig,
- scope: CoroutineScope,
- prefs: HomePagePreferences,
- userDto: ServerUserConfig,
- libraries: List,
- limit: Int = prefs.maxItemsPerRow,
- isRefresh: Boolean,
- usePaging: Boolean = false,
- ): HomeRowLoadingState =
- when (row) {
- is HomeRowConfig.ContinueWatching -> {
- val resume =
- latestNextUpService.getResume(
- userDto.id,
- limit,
- true,
- row.viewOptions.useSeries,
- )
-
- Success(
- title = ResStringProvider(R.string.continue_watching),
- items = resume,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = resume.size >= limit,
- )
- }
-
- is HomeRowConfig.NextUp -> {
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- prefs.maxDaysNextUp,
- row.viewOptions.useSeries,
- )
-
- Success(
- title = ResStringProvider(R.string.next_up),
- items = nextUp,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = nextUp.size >= limit,
- )
- }
-
- is HomeRowConfig.ContinueWatchingCombined -> {
- val resume =
- latestNextUpService.getResume(
- userDto.id,
- limit,
- true,
- row.viewOptions.useSeries,
- )
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- prefs.maxDaysNextUp,
- row.viewOptions.useSeries,
- )
- val combined = latestNextUpService.buildCombined(resume, nextUp)
-
- Success(
- title = ResStringProvider(R.string.continue_watching),
- items = combined.take(limit),
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = combined.size >= limit,
- )
- }
-
- is HomeRowConfig.Genres -> {
- val request =
- GetGenresRequest(
- parentId = row.parentId,
- userId = userDto.id,
- limit = limit,
- )
- val items =
- GetGenresRequestHandler
- .execute(api, request)
- .content.items
- val genreIds = items.map { it.id }
- val genreImages =
- getGenreImageMap(
- api = api,
- userId = serverRepository.currentUser?.id,
- scope = scope,
- imageUrlService = imageUrlService,
- genres = genreIds,
- parentId = row.parentId,
- includeItemTypes = null,
- cardWidthPx = null,
- useCache = isRefresh,
- )
- val library =
- libraries
- .firstOrNull { it.itemId == row.parentId }
-
- val title =
- library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
- ?: ResStringProvider(R.string.genres)
- val genres =
- items.map {
- BaseItem(
- it,
- false,
- genreImages[it.id],
- createGenreDestination(
- genreId = it.id,
- genreName = it.name ?: "",
- parentId = row.parentId,
- parentName = library?.name,
- includeItemTypes = library?.includeItemTypes,
- collectionType =
- library?.collectionType
- ?: CollectionType.UNKNOWN,
- ),
- )
- }
-
- Success(
- title,
- genres,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = genres.size >= limit,
- )
- }
-
- is HomeRowConfig.Studios -> {
- val request =
- GetStudiosRequest(
- parentId = row.parentId,
- userId = userDto.id,
- limit = limit,
- includeItemTypes = listOf(BaseItemKind.SERIES),
- )
- val items =
- GetStudiosRequestHandler
- .execute(api, request)
- .content.items
- val library =
- libraries
- .firstOrNull { it.itemId == row.parentId }
- val title =
- library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
- ?: ResStringProvider(R.string.studios)
- val studios =
- items.map {
- val imageUrl =
- imageUrlService.getItemImageUrl(
- itemId = it.id,
- imageType = ImageType.THUMB,
- tag = it.imageTags?.get(ImageType.THUMB),
- )
- BaseItem(
- it,
- false,
- imageUrl,
- createStudioDestination(
- studioId = it.id,
- name = it.name ?: "",
- parentId = row.parentId,
- parentName = library?.name,
- includeItemTypes = library?.includeItemTypes,
- ),
- )
- }
-
- Success(
- title,
- studios,
- rowType = row,
- viewOptions = row.viewOptions,
- showViewMore = studios.size >= limit,
- )
- }
-
- is HomeRowConfig.RecentlyAdded -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val title = getRecentlyAddedTitle(library?.name)
- val request =
- GetLatestMediaRequest(
- fields = library.itemFields,
- imageTypeLimit = 1,
- parentId = row.parentId,
- groupItems = true,
- limit = limit,
- isPlayed = null, // Server will handle user's preference
- )
- val latest =
- api.userLibraryApi
- .getLatestMedia(request)
- .content
- .map { BaseItem(it, row.viewOptions.useSeries) }
- .let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- latest
- }
-
- is HomeRowConfig.RecentlyReleased -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val title =
- library?.name?.let {
- ResArgStringProvider(R.string.recently_released_in, it)
- } ?: ResStringProvider(R.string.recently_released)
- val request =
- GetItemsRequest(
- parentId = row.parentId,
- limit = limit,
- sortBy =
- listOf(
- ItemSortBy.PREMIERE_DATE,
- ItemSortBy.SERIES_SORT_NAME,
- ItemSortBy.AIRED_EPISODE_ORDER,
- ),
- sortOrder =
- listOf(
- SortOrder.DESCENDING,
- SortOrder.ASCENDING,
- SortOrder.DESCENDING,
- ),
- fields = library.itemFields,
- recursive = true,
- maxPremiereDate = LocalDateTime.now(),
- isUnaired = false,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.ByParent -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val request =
- GetItemsRequest(
- userId = userDto.id,
- parentId = row.parentId,
- recursive = row.recursive,
- sortBy =
- row.sort?.let {
- buildList {
- if (it.sort == ItemSortBy.RANDOM) {
- add(ItemSortBy.SORT_NAME)
- add(ItemSortBy.RANDOM)
- } else {
- add(it.sort)
- if (it.sort != ItemSortBy.SORT_NAME) {
- add(ItemSortBy.SORT_NAME)
- }
- }
- }
- },
- sortOrder =
- row.sort?.let {
- buildList {
- if (it.sort == ItemSortBy.RANDOM) {
- add(SortOrder.ASCENDING)
- add(it.direction)
- } else {
- add(it.direction)
- if (it.sort != ItemSortBy.SORT_NAME) {
- add(SortOrder.ASCENDING)
- }
- }
- }
- },
- limit = limit,
- fields = library.itemFields,
- )
-
- // Not using getItemName because we want to throw the 404
- val title =
- api.userLibraryApi
- .getItem(
- userId = serverRepository.currentUser?.id,
- itemId = row.parentId,
- ).content.name
- ?.let { StringStringProvider(it) }
- ?: ResStringProvider(R.string.collection)
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.GetItems -> {
- val request =
- row.filter.asGetItemsRequest().copy(
- userId = userDto.id,
- limit = limit,
- fields = HomeItemFieldsBoxSets,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- StringStringProvider(row.name),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Favorite -> {
- val title =
- ResProviderStringProvider(
- R.string.favorite_items_title,
- ResStringProvider(formatTypeName(row.kind)),
- )
- val resultList =
- when (row.kind) {
- BaseItemKind.PERSON -> {
- val request =
- GetPersonsRequest(
- userId = userDto.id,
- limit = limit,
- fields = HomeItemFields,
- isFavorite = true,
- enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY),
- )
-
- GetPersonsHandler.execute(api, request).toBaseItems()
- }
-
- BaseItemKind.MUSIC_ARTIST -> {
- val request =
- GetArtistsRequest(
- userId = userDto.id,
- limit = limit,
- fields = HomeItemFields,
- isFavorite = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetArtistsHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetArtistsHandler
- .execute(api, request)
- .toBaseItems(row.viewOptions.useSeries)
- }
- }
-
- else -> {
- val fields =
- if (row.kind == BaseItemKind.BOX_SET) {
- HomeItemFieldsBoxSets
- } else {
- HomeItemFields
- }
- val request =
- GetItemsRequest(
- userId = userDto.id,
- recursive = true,
- limit = limit,
- fields = fields,
- includeItemTypes = listOf(row.kind),
- isFavorite = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .toBaseItems(row.viewOptions.useSeries)
- }
- }
- }
- resultList.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Recordings -> {
- val request =
- GetRecordingsRequest(
- userId = userDto.id,
- isInProgress = true,
- fields = HomeItemFields,
- limit = limit,
- enableImages = true,
- enableUserData = true,
- enableTotalRecordCount = false,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetRecordingsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getRecordings(request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- ResStringProvider(R.string.active_recordings),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.TvPrograms -> {
- val request =
- GetProgramsDto(
- userId = userDto.id,
- fields = ProgramItemFields,
- limit = limit,
- enableUserData = true,
- enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
- imageTypeLimit = 1,
- isAiring = true,
- minEndDate = DateTime.now().plusMinutes(1),
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetProgramsDtoHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getPrograms(request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- ResStringProvider(R.string.watch_live),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.TvChannels -> {
- val request =
- GetLiveTvChannelsRequest(
- userId = userDto.id,
- fields = HomeItemFields,
- limit = limit,
- enableImages = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetLiveTvChannelsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getLiveTvChannels(request)
- .toBaseItems(api, row.viewOptions.useSeries)
- }.let {
- Success(
- ResStringProvider(R.string.channels),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Suggestions -> {
- val library =
- api.userLibraryApi
- .getItem(itemId = row.parentId)
- .content
- val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
- val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
- if (itemKind != null) {
- val suggestions =
- suggestionService
- .getSuggestionsFlow(row.parentId, itemKind)
- .firstOrNull()
- when (suggestions) {
- SuggestionsResource.Empty -> {
- Success(
- title,
- listOf(),
- row.viewOptions,
- rowType = row,
- )
- }
-
- is SuggestionsResource.Success -> {
- Success(
- title,
- suggestions.items,
- row.viewOptions,
- rowType = row,
- showViewMore = suggestions.items.size >= limit,
- )
- }
-
- SuggestionsResource.Loading,
- null,
- -> {
- HomeRowLoadingState.Loading(title)
- }
- }
- } else {
- HomeRowLoadingState.Error(
- title = title,
- message = "Unsupported type ${library.collectionType}",
- )
- }
- }
-
- is HomeRowConfig.CustomEndpoint -> {
- val title = StringStringProvider(row.title)
- try {
- val items =
- fetchCustomEndpointItems(row)
- .map { BaseItem(it, row.viewOptions.useSeries) }
- Success(title, items, row.viewOptions, rowType = row)
- } catch (ex: Exception) {
- Timber.w(ex, "Custom endpoint %s failed", row.endpoint)
- HomeRowLoadingState.Error(title, exception = ex)
- }
- }
- }
-
- private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List {
- val base =
- api.baseUrl?.toHttpUrlOrNull()
- ?: throw IllegalStateException("Jellyfin baseUrl not set")
- val endpointUri = row.endpoint.toUri()
- if (!endpointUri.isRelative) {
- throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}")
- }
- val params =
- buildMap {
- serverRepository.currentUser
- ?.id
- ?.toString()
- ?.let { put("userId", it) }
- row.query?.forEach { put(it.key, it.value) }
- }
- val resolved =
- base
- .resolve(row.endpoint)
- ?.newBuilder()
- ?.apply {
- params.forEach { (k, v) -> addQueryParameter(k, v) }
- }?.build()
- ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}")
- val request =
- Request
- .Builder()
- .url(resolved)
- .get()
- .apply {
- row.headers?.forEach { header(it.key, it.value) }
- }.build()
- val response =
- authOkHttpClient
- .newCall(request)
- .execute()
- return response.use {
- if (!it.isSuccessful) {
- throw InvalidStatusException(it.code, null)
- }
- val queryResult =
- HomeSettingsService.jsonParser.decodeFromStream(it.body.byteStream())
- Timber.v("Got %s items from custom endpoint", queryResult.items.size)
- queryResult.items
- }
- }
- }
-
-private val Library?.itemFields: List
- get() =
- if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
- // Get child count to show in the header
- HomeItemFieldsBoxSets
- } else {
- HomeItemFields
- }
-
-private val Library.includeItemTypes: List?
- get() =
- collectionType.let { collectionType ->
- getTypeFor(collectionType)?.let { type -> listOf(type) }
- }
-
-private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
index 1c05640e..c070b37e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -2,27 +2,48 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.StringRes
-import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
-import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
+import com.github.damontecres.wholphin.data.model.ServerUserConfig
+import com.github.damontecres.wholphin.data.model.createGenreDestination
+import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
+import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
+import com.github.damontecres.wholphin.ui.HomeItemFields
+import com.github.damontecres.wholphin.ui.ProgramItemFields
+import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
+import com.github.damontecres.wholphin.ui.main.settings.Library
+import com.github.damontecres.wholphin.ui.playback.getTypeFor
+import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.GetArtistsHandler
+import com.github.damontecres.wholphin.util.GetGenresRequestHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
+import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
+import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
+import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -34,15 +55,32 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.api.GetProgramsDto
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.UserDto
+import org.jellyfin.sdk.model.api.request.GetArtistsRequest
+import org.jellyfin.sdk.model.api.request.GetGenresRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
+import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
+import org.jellyfin.sdk.model.api.request.GetPersonsRequest
+import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
+import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
import java.io.File
+import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
-import kotlin.coroutines.cancellation.CancellationException
/**
* Handles getting home page settings and data
@@ -54,11 +92,21 @@ class HomeSettingsService
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
+ private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
+ private val latestNextUpService: LatestNextUpService,
+ private val imageUrlService: ImageUrlService,
+ private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService,
- private val serverPluginApi: ServerPluginApi,
- private val serverDao: JellyfinServerDao,
) {
+ @OptIn(ExperimentalSerializationApi::class)
+ val jsonParser =
+ Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ allowTrailingComma = true
+ }
+
/**
* The current home page settings
*/
@@ -140,8 +188,9 @@ class HomeSettingsService
/**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
+ *
+ * This is public only for testing
*/
- @VisibleForTesting
fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
@@ -162,42 +211,6 @@ class HomeSettingsService
return HomePageSettings(rows, version)
}
- private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
- try {
- block.invoke()
- } catch (ex: Exception) {
- Timber.w(ex, "Error loading settings")
- null
- }
-
- suspend fun fetchSettingsFor(
- userId: UUID,
- source: HomePageSettingsSource,
- ): HomePageResolvedSettings? {
- val (resolvedSource, settings) =
- when (source) {
- HomePageSettingsSource.UNSET -> loadInOrder(userId)
- HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId)
- HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId)
- HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings()
- HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings()
- HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings()
- }
- Timber.i(
- "fetch home settings: resolvedSource=%s, userId=%s, found=%s",
- resolvedSource,
- userId,
- settings != null,
- )
- return settings?.let {
- val resolvedRows =
- settings.rows.mapIndexed { index, config ->
- resolve(index, config)
- }
- HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
- }
- }
-
/**
* Loads [HomePageSettings] into [currentSettings]
*
@@ -205,70 +218,55 @@ class HomeSettingsService
*
* Does not persist either the server nor default
*/
- suspend fun loadCurrentSettings(user: JellyfinUser) {
- currentSettings.update { HomePageResolvedSettings.EMPTY }
-
- Timber.v("Getting setting for %s", user.id)
+ suspend fun loadCurrentSettings(userId: UUID) {
+ Timber.v("Getting setting for %s", userId)
+ // User local then server/remote otherwise create a default
val settings =
try {
- fetchSettingsFor(user.id, user.config.homeSettingsSource)
- } catch (ex: CancellationException) {
- throw ex
+ val local = loadFromLocal(userId)
+ Timber.v("Found local? %s", local != null)
+ local
} catch (ex: Exception) {
- Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
+ Timber.w(ex, "Error loading local settings")
+ // TODO show toast?
+ null
+ } ?: try {
+ val remote = loadFromServer(userId)
+ Timber.v("Found remote? %s", remote != null)
+ remote
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading remote settings")
null
}
- Timber.v("Found settings: %s", settings != null)
- val resolvedSettings = settings ?: createDefault(user.id)
+ val resolvedSettings =
+ if (settings != null) {
+ Timber.v("Found settings")
+ // Resolve
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ HomePageResolvedSettings(userId, resolvedRows)
+ } else {
+ createDefault(userId)
+ }
+
currentSettings.update { resolvedSettings }
}
- /**
- * Tries to load settings from local->server->plugin
- */
- private suspend fun loadInOrder(userId: UUID): Pair {
- Timber.v("loadInOrder: local")
- var settings =
- HomePageSettingsSource.LOCAL to
- tryLoad {
- loadFromLocal(userId)
- }
-
- if (settings.second == null) {
- Timber.v("loadInOrder: server")
- settings =
- HomePageSettingsSource.SERVER_PROFILE to
- tryLoad {
- loadFromServer(userId)
- }
- }
-
- if (settings.second == null && serverRepository.serverPluginInstalled.value) {
- Timber.v("loadInOrder: plugin")
- settings = HomePageSettingsSource.PLUGIN to
- tryLoad {
- serverPluginApi.fetchHomePageSettings()
- }
- }
- return settings
- }
-
/**
* Resolve the settings and set them to be the current settings
*/
suspend fun updateCurrent(
- source: HomePageSettingsSource,
+ userId: UUID,
settings: HomePageSettings,
) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
- currentSettings.update { it.copy(rows = resolvedRows) }
- serverRepository.currentUser?.let { user ->
- val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
- serverDao.updateUser(toSave)
- }
+ val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
+ currentSettings.update { resolvedSettings }
}
/**
@@ -318,7 +316,7 @@ class HomeSettingsService
),
)
val rowConfig = continueWatchingRow + includedIds
- return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
+ return HomePageResolvedSettings(userId, rowConfig)
}
/**
@@ -434,7 +432,7 @@ class HomeSettingsService
null
}
}.flatten()
- HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
+ HomePageResolvedSettings(userId, rowConfigs)
} else {
null
}
@@ -562,14 +560,6 @@ class HomeSettingsService
config,
)
}
-
- is HomeRowConfig.CustomEndpoint -> {
- HomeRowConfigDisplay(
- id = id,
- title = StringStringProvider(config.title),
- config,
- )
- }
}
private suspend fun getItemName(
@@ -595,16 +585,631 @@ class HomeSettingsService
ResStringProvider(R.string.unknown)
}
+ /**
+ * Fetch the data from the server for a given [HomeRowConfig]
+ */
+ suspend fun fetchDataForRow(
+ row: HomeRowConfig,
+ scope: CoroutineScope,
+ prefs: HomePagePreferences,
+ userDto: ServerUserConfig,
+ libraries: List,
+ limit: Int = prefs.maxItemsPerRow,
+ isRefresh: Boolean,
+ usePaging: Boolean = false,
+ ): HomeRowLoadingState =
+ when (row) {
+ is HomeRowConfig.ContinueWatching -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = ResStringProvider(R.string.continue_watching),
+ items = resume,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = resume.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.NextUp -> {
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = ResStringProvider(R.string.next_up),
+ items = nextUp,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = nextUp.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+ val combined = latestNextUpService.buildCombined(resume, nextUp)
+
+ Success(
+ title = ResStringProvider(R.string.continue_watching),
+ items = combined.take(limit),
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = combined.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val request =
+ GetGenresRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ )
+ val items =
+ GetGenresRequestHandler
+ .execute(api, request)
+ .content.items
+ val genreIds = items.map { it.id }
+ val genreImages =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser?.id,
+ scope = scope,
+ imageUrlService = imageUrlService,
+ genres = genreIds,
+ parentId = row.parentId,
+ includeItemTypes = null,
+ cardWidthPx = null,
+ useCache = isRefresh,
+ )
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+
+ val title =
+ library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
+ ?: ResStringProvider(R.string.genres)
+ val genres =
+ items.map {
+ BaseItem(
+ it,
+ false,
+ genreImages[it.id],
+ createGenreDestination(
+ genreId = it.id,
+ genreName = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes =
+ library?.collectionType?.let {
+ getTypeFor(it)?.let {
+ listOf(it)
+ }
+ },
+ collectionType =
+ library?.collectionType
+ ?: CollectionType.UNKNOWN,
+ ),
+ )
+ }
+
+ Success(
+ title,
+ genres,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = genres.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.Studios -> {
+ val request =
+ GetStudiosRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ includeItemTypes = listOf(BaseItemKind.SERIES),
+ )
+ val items =
+ GetStudiosRequestHandler
+ .execute(api, request)
+ .content.items
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ val title =
+ library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
+ ?: ResStringProvider(R.string.studios)
+ val studios =
+ items.map {
+ val imageUrl =
+ imageUrlService.getItemImageUrl(
+ itemId = it.id,
+ imageType = ImageType.THUMB,
+ )
+ BaseItem(
+ it,
+ false,
+ imageUrl,
+ createStudioDestination(
+ studioId = it.id,
+ name = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes =
+ library?.collectionType?.let {
+ getTypeFor(it)?.let {
+ listOf(it)
+ }
+ },
+ ),
+ )
+ }
+
+ Success(
+ title,
+ studios,
+ viewOptions = row.viewOptions,
+ showViewMore = studios.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val title = getRecentlyAddedTitle(library?.name)
+ val request =
+ GetLatestMediaRequest(
+ fields = library.itemFields,
+ imageTypeLimit = 1,
+ parentId = row.parentId,
+ groupItems = true,
+ limit = limit,
+ isPlayed = null, // Server will handle user's preference
+ )
+ val latest =
+ api.userLibraryApi
+ .getLatestMedia(request)
+ .content
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ latest
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val title =
+ library?.name?.let {
+ ResArgStringProvider(R.string.recently_released_in, it)
+ } ?: ResStringProvider(R.string.recently_released)
+ val request =
+ GetItemsRequest(
+ parentId = row.parentId,
+ limit = limit,
+ sortBy =
+ listOf(
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.SERIES_SORT_NAME,
+ ItemSortBy.AIRED_EPISODE_ORDER,
+ ),
+ sortOrder =
+ listOf(
+ SortOrder.DESCENDING,
+ SortOrder.ASCENDING,
+ SortOrder.DESCENDING,
+ ),
+ fields = library.itemFields,
+ recursive = true,
+ maxPremiereDate = LocalDateTime.now(),
+ isUnaired = false,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem.from(it, api, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ parentId = row.parentId,
+ recursive = row.recursive,
+ sortBy =
+ row.sort?.let {
+ buildList {
+ if (it.sort == ItemSortBy.RANDOM) {
+ add(ItemSortBy.SORT_NAME)
+ add(ItemSortBy.RANDOM)
+ } else {
+ add(it.sort)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(ItemSortBy.SORT_NAME)
+ }
+ }
+ }
+ },
+ sortOrder =
+ row.sort?.let {
+ buildList {
+ if (it.sort == ItemSortBy.RANDOM) {
+ add(SortOrder.ASCENDING)
+ add(it.direction)
+ } else {
+ add(it.direction)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(SortOrder.ASCENDING)
+ }
+ }
+ }
+ },
+ limit = limit,
+ fields = library.itemFields,
+ )
+
+ // Not using getItemName because we want to throw the 404
+ val title =
+ api.userLibraryApi
+ .getItem(
+ userId = serverRepository.currentUser?.id,
+ itemId = row.parentId,
+ ).content.name
+ ?.let { StringStringProvider(it) }
+ ?: ResStringProvider(R.string.collection)
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.GetItems -> {
+ val request =
+ row.getItems.let {
+ if (it.limit == null) {
+ it.copy(
+ userId = userDto.id,
+ limit = limit,
+ )
+ } else {
+ it.copy(
+ userId = userDto.id,
+ )
+ }
+ }
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ StringStringProvider(row.name),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val title =
+ ResProviderStringProvider(
+ R.string.favorite_items_title,
+ ResStringProvider(formatTypeName(row.kind)),
+ )
+ val resultList =
+ when (row.kind) {
+ BaseItemKind.PERSON -> {
+ val request =
+ GetPersonsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFields,
+ isFavorite = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ )
+
+ GetPersonsHandler.execute(api, request).toBaseItems()
+ }
+
+ BaseItemKind.MUSIC_ARTIST -> {
+ val request =
+ GetArtistsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFields,
+ isFavorite = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetArtistsHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetArtistsHandler
+ .execute(api, request)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
+ }
+
+ else -> {
+ val fields =
+ if (row.kind == BaseItemKind.BOX_SET) {
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ recursive = true,
+ limit = limit,
+ fields = fields,
+ includeItemTypes = listOf(row.kind),
+ isFavorite = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
+ }
+ }
+ resultList.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Recordings -> {
+ val request =
+ GetRecordingsRequest(
+ userId = userDto.id,
+ isInProgress = true,
+ fields = HomeItemFields,
+ limit = limit,
+ enableImages = true,
+ enableUserData = true,
+ enableTotalRecordCount = false,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetRecordingsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getRecordings(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ ResStringProvider(R.string.active_recordings),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ val request =
+ GetProgramsDto(
+ userId = userDto.id,
+ fields = ProgramItemFields,
+ limit = limit,
+ enableUserData = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
+ imageTypeLimit = 1,
+ isAiring = true,
+ minEndDate = DateTime.now().plusMinutes(1),
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetProgramsDtoHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getPrograms(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ ResStringProvider(R.string.watch_live),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ val request =
+ GetLiveTvChannelsRequest(
+ userId = userDto.id,
+ fields = HomeItemFields,
+ limit = limit,
+ enableImages = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetLiveTvChannelsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getLiveTvChannels(request)
+ .toBaseItems(api, row.viewOptions.useSeries)
+ }.let {
+ Success(
+ ResStringProvider(R.string.channels),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val library =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content
+ val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
+ val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
+ if (itemKind != null) {
+ val suggestions =
+ suggestionService
+ .getSuggestionsFlow(row.parentId, itemKind)
+ .firstOrNull()
+ when (suggestions) {
+ SuggestionsResource.Empty -> {
+ Success(
+ title,
+ listOf(),
+ row.viewOptions,
+ rowType = row,
+ )
+ }
+
+ is SuggestionsResource.Success -> {
+ Success(
+ title,
+ suggestions.items,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = suggestions.items.size >= limit,
+ )
+ }
+
+ SuggestionsResource.Loading,
+ null,
+ -> {
+ HomeRowLoadingState.Loading(title)
+ }
+ }
+ } else {
+ HomeRowLoadingState.Error(
+ title = title,
+ message = "Unsupported type ${library.collectionType}",
+ )
+ }
+ }
+ }
+
companion object {
const val CUSTOM_PREF_ID = "home_settings"
-
- @OptIn(ExperimentalSerializationApi::class)
- val jsonParser =
- Json {
- isLenient = true
- ignoreUnknownKeys = true
- allowTrailingComma = true
- }
}
}
@@ -624,14 +1229,10 @@ data class HomeRowConfigDisplay(
*/
data class HomePageResolvedSettings(
val userId: UUID,
- val source: HomePageSettingsSource,
val rows: List,
) {
- fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
-
companion object {
- val EMPTY =
- HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
+ val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
}
}
@@ -665,6 +1266,17 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
+private val Library?.itemFields: List
+ get() =
+ if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
+ // Get child count to show in the header
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+
+private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
+
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) {
CollectionType.MUSIC,
@@ -703,14 +1315,3 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
HomeRowViewOptions()
}
}
-
-enum class HomePageSettingsSource(
- @param:StringRes val stringResId: Int,
-) {
- UNSET(R.string.unknown),
- LOCAL(R.string.home_settings_source_local),
- SERVER_PROFILE(R.string.home_settings_source_server_profile),
- PLUGIN(R.string.home_settings_source_plugin),
- DEFAULT(R.string.home_settings_source_unset),
- WEB_CONFIG(R.string.home_settings_source_web),
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index 967629da..6b71d683 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -35,11 +35,6 @@ class ImageUrlService
parentBackdropId: UUID? = null,
fillWidth: Int? = null,
fillHeight: Int? = null,
- seriesPrimaryTag: String? = null,
- parentLogoId: UUID? = null,
- parentLogoTag: String? = null,
- parentThumbTag: String? = null,
- parentBackdropTags: List = emptyList(),
): String? =
when (imageType) {
ImageType.LOGO -> {
@@ -49,7 +44,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = parentLogoTag.takeIf { parentLogoId == seriesId },
)
} else {
getItemImageUrl(
@@ -57,7 +51,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = imageTags[imageType],
)
}
}
@@ -70,7 +63,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
)
} else if (backdropTags.isNotEmpty()) {
getItemImageUrl(
@@ -78,7 +70,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = backdropTags.first(),
)
} else {
null
@@ -95,7 +86,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = parentThumbTag,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
@@ -106,7 +96,6 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = parentBackdropTags.firstOrNull(),
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@@ -114,7 +103,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = parentThumbTag,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
@@ -127,7 +115,6 @@ class ImageUrlService
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = imageTags[ImageType.PRIMARY],
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
@@ -136,7 +123,6 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = backdropTags.first(),
)
} else {
getItemImageUrl(
@@ -144,7 +130,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = imageTags[imageType],
)
}
}
@@ -160,7 +145,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@@ -168,7 +152,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else {
getItemImageUrl(
@@ -176,7 +159,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = imageTags[imageType],
)
}
}
@@ -187,7 +169,6 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
- tag = imageTags[imageType],
)
}
}
@@ -212,11 +193,6 @@ class ImageUrlService
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
- seriesPrimaryTag = item.data.seriesPrimaryImageTag,
- parentLogoId = item.data.parentLogoItemId,
- parentLogoTag = item.data.parentLogoImageTag,
- parentThumbTag = item.data.parentThumbImageTag,
- parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
)
} else {
null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
index 4fda8260..2dd0cde5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
@@ -2,8 +2,6 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
-import androidx.annotation.VisibleForTesting
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
@@ -116,7 +114,6 @@ class IntentService
return IntentResult.Target(destinations, true)
}
- @VisibleForTesting
internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences
@@ -124,33 +121,39 @@ class IntentService
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) {
Timber.v("Intent switches user")
- val user = serverRepository.tryChangeUser(serverId, userId)
- if (user != null) {
+ val user = serverRepository.serverDao.getUser(serverId, userId)
+ if (user != null && !user.isProtected) {
+ serverRepository.restoreSession(serverId, userId)
+ ?: IntentResult.Error("Error restoring user")
null
} else {
- IntentResult.Error("Error restoring user")
+ IntentResult.Error("Cannot switch to specified user")
}
- } else if (appPrefs.signInAutomatically) {
- Timber.v("No current user, so restoring last")
- val result = serverRepository.restoreLastSession()
- Timber.v("Restored result=%s", result)
- when (result) {
- RestoredSession.None,
- is RestoredSession.ServerOnly,
- -> {
- IntentResult.Error("Could not auto sign-in, specify a server & user")
- }
+ } else {
+ val profileProtected =
+ serverRepository.current.value
+ ?.user
+ ?.isProtected == true
+ if (appPrefs.signInAutomatically && !profileProtected) {
+ Timber.v("No current user, so restoring last")
+ val userId = appPrefs.currentUserId?.toUUIDOrNull()
+ val serverId = appPrefs.currentServerId?.toUUIDOrNull()
- is RestoredSession.Success -> {
- if (result.currentUser.user.isProtected) {
+ if (userId != null && serverId != null) {
+ val current =
+ serverRepository.restoreSession(serverId, userId)
+ ?: return IntentResult.Error("Error restoring user")
+ if (current.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected")
} else {
null
}
+ } else {
+ IntentResult.Error("Could not auto sign-in, specify a server & user")
}
+ } else {
+ IntentResult.Error("Auto sign-in not enabled or user is protected")
}
- } else {
- IntentResult.Error("Auto sign-in not enabled")
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
index a60bf7c4..01ed9dd2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
@@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import android.content.Context
-import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope
@@ -14,7 +13,6 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -54,8 +52,12 @@ class LatestNextUpWorker
try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
- val result = serverRepository.restoreLastSession()
- if (result !is RestoredSession.Success) {
+ var currentUser = serverRepository.current.value
+ if (currentUser == null) {
+ serverRepository.restoreSession(serverId, userId)
+ currentUser = serverRepository.current.value
+ }
+ if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
@@ -89,7 +91,7 @@ class LatestNextUpSchedulerService
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
- @VisibleForTesting
+ // Exposed for testing
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
init {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
index 16ac4edd..ec6e9d2c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -122,11 +122,7 @@ class MusicService
mutex.withLock {
if (mediaSession == null) {
Timber.i("Starting music MediaSession")
- mediaSession =
- MediaSession
- .Builder(context, player)
- .setCallback(TrustAllControllersCallback)
- .build()
+ mediaSession = MediaSession.Builder(context, player).build()
activityTracker =
TrackActivityPlaybackListener(api, player) {
state.value.currentItemId?.let { itemId ->
@@ -331,7 +327,6 @@ class MusicService
imageUrlService.getItemImageUrl(
itemId = albumId,
imageType = ImageType.PRIMARY,
- tag = audio.data.albumPrimaryImageTag,
)
}
return MediaItem
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
index a2fd0eb7..9b16718d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
@@ -24,7 +24,6 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory
-import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -33,15 +32,9 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
-import com.github.damontecres.wholphin.util.BitstreamFilter
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
-import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
-import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
-import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
import com.github.damontecres.wholphin.util.WholphinDispatchers
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
@@ -64,7 +57,6 @@ class PlayerFactory
constructor(
@param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
- private val deviceProfileService: DeviceProfileService,
) {
@Volatile
var currentPlayer: Player? = null
@@ -102,16 +94,10 @@ class PlayerFactory
val preferDolbyVision =
appPreferences.experimentalPreferences
.get { preferDolbyVisionOverHdr10Plus } ?: false
- val convertDolbyVisionProfile7 =
- DolbyVisionProfile7Conversion.isWanted(
- appPreferences.experimentalPreferences,
- deviceProfileService.mediaCodecCapabilitiesTest,
- )
Timber.v(
- "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
+ "extensions=%s, assPlaybackMode=%s",
extensions,
prefs.overrides.assPlaybackMode,
- convertDolbyVisionProfile7,
)
val rendererMode =
when (extensions) {
@@ -122,18 +108,9 @@ class PlayerFactory
}
val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = createExtractorsFactory()
-
- // Applied outermost so that a profile 7 track is advertised as 8.1 whichever
- // extractor, media3's own or libass's Matroska replacement, produced it
- fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
- if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
var renderersFactory: RenderersFactory =
- WholphinRenderersFactory(
- context,
- decodeAv1,
- preferDolbyVision,
- convertDolbyVisionProfile7,
- ).setEnableDecoderFallback(true)
+ WholphinRenderersFactory(context, decodeAv1, preferDolbyVision)
+ .setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
@@ -149,16 +126,15 @@ class PlayerFactory
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(
dataSourceFactory,
- extractorsFactory
- .withAssMkvSupport(
- assSubtitleParserFactory,
- assHandler,
- ).forDolbyVision(),
+ extractorsFactory.withAssMkvSupport(
+ assSubtitleParserFactory,
+ assHandler,
+ ),
).setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(
dataSourceFactory,
- extractorsFactory.forDolbyVision(),
+ extractorsFactory,
)
}
val disableAudioOffload =
@@ -184,9 +160,8 @@ class PlayerFactory
AudioAttributes
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
- .setUsage(C.USAGE_MEDIA)
.build(),
- true,
+ false,
)
}
}
@@ -271,29 +246,9 @@ class PlayerFactory
fun createMediaSession(player: Player) =
MediaSession
.Builder(context, player)
- .setCallback(TrustAllControllersCallback)
.build()
}
-/**
- * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`)
- * only get read-only player commands by default, so play/pause from them is silently dropped.
- * Restore the pre-1.11 behavior of granting all player commands to every controller.
- */
-@OptIn(UnstableApi::class)
-object TrustAllControllersCallback : MediaSession.Callback {
- override fun onConnectAsync(
- session: MediaSession,
- controller: MediaSession.ControllerInfo,
- ): ListenableFuture =
- Futures.immediateFuture(
- MediaSession.ConnectionResult
- .AcceptedResultBuilder(session, controller)
- .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
- .build(),
- )
-}
-
val Player.isReleased: Boolean
get() {
return when (this) {
@@ -313,7 +268,6 @@ class WholphinRenderersFactory(
context: Context,
private val av1Enabled: Boolean,
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
- private val convertDolbyVisionProfile7: Boolean = false,
) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class)
override fun buildVideoRenderers(
@@ -326,16 +280,12 @@ class WholphinRenderersFactory(
allowedVideoJoiningTimeMs: Long,
out: ArrayList,
) {
- // The conversion goes first: it may shorten the access unit, and the masking then only
- // has to walk what is left of it
- val bitstreamFilters =
- buildList {
- if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
- if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
- }
val videoCodecAdapterFactory =
- if (bitstreamFilters.isNotEmpty()) {
- BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
+ if (preferDolbyVisionOverHdr10Plus) {
+ BitstreamFilteringCodecAdapterFactory(
+ codecAdapterFactory,
+ listOf(Hdr10PlusMaskingFilter()),
+ )
} else {
codecAdapterFactory
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index 0d0eb0b6..90d9526b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -9,24 +9,20 @@ import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
-import com.github.damontecres.wholphin.data.model.SeerrPluginLogin
-import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType
-import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -43,25 +39,26 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
- private val _connection = MutableStateFlow(SeerrState())
- val connection: StateFlow = _connection
+ private val _connection =
+ MutableStateFlow(SeerrConnectionStatus.NotConfigured)
+ val connection: StateFlow = _connection
val current: Flow =
- _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
+ _connection.map { (it as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow =
- connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow =
- connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
+ connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow = current.map { it?.config?.id }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow =
- connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
+ connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
- _connection.update { SeerrState() }
+ _connection.update { SeerrConnectionStatus.NotConfigured }
seerrApi.update("", null)
}
@@ -70,7 +67,7 @@ class SeerrServerRepository
user: SeerrUser,
exception: Exception,
) {
- _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
+ _connection.update { SeerrConnectionStatus.Error(server, user, exception) }
seerrApi.update("", null)
}
@@ -81,9 +78,8 @@ class SeerrServerRepository
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_connection.update {
- it.copy(
- connection =
- SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
+ SeerrConnectionStatus.Success(
+ CurrentSeerr(server, user, userConfig, publicSettings),
)
}
}
@@ -173,7 +169,7 @@ class SeerrServerRepository
suspend fun removeServerForCurrentUser(): Boolean {
val user =
- when (val conn = connection.value.connection) {
+ when (val conn = connection.first()) {
SeerrConnectionStatus.NotConfigured -> return false
is SeerrConnectionStatus.Error -> conn.user
is SeerrConnectionStatus.Success -> conn.current.user
@@ -182,81 +178,6 @@ class SeerrServerRepository
clear()
return rows > 0
}
-
- /**
- * Given the [SeerrPluginSettings], try to connect to the Seerr server
- *
- * @return true if successfully updated the SeerrApi and logged in
- */
- suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
- val currentUser = serverRepository.currentUser ?: return false
- if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
- val server =
- SeerrServer(
- id = -1,
- url = settings.serverUrl,
- )
- val user =
- SeerrUser(
- jellyfinUserRowId = currentUser.rowId,
- serverId = server.id,
- authMethod =
- when (settings.login) {
- is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
- is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
- },
- username =
- when (settings.login) {
- is SeerrPluginLogin.ApiKey -> null
- is SeerrPluginLogin.Local -> settings.login.local.username
- },
- password = null,
- credential = null,
- )
- try {
- val userConfig =
- when (settings.login) {
- is SeerrPluginLogin.ApiKey -> {
- Timber.v("maybeAddPluginServer, using API key")
- seerrApi.update(settings.serverUrl, settings.login.apiKey)
- seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
- }
-
- is SeerrPluginLogin.Local -> {
- Timber.v("maybeAddPluginServer, using local user")
- seerrApi.update(settings.serverUrl, null)
- seerrLogin(
- seerrApi.api,
- SeerrAuthMethod.LOCAL,
- settings.login.local.username,
- settings.login.local.password,
- )
- }
-
- SeerrPluginLogin.None -> {
- return false
- }
- }
- // Success
- _connection.update { it.copy(pluginProvided = true) }
- set(server, user, userConfig)
- return true
- } catch (ex: CancellationException) {
- throw ex
- } catch (ex: Exception) {
- clear()
- Timber.w(ex, "Error adding plugin provided seerr")
- _connection.update {
- it.copy(
- pluginProvided = true,
- connection = SeerrConnectionStatus.Error(server, user, ex),
- )
- }
- return false
- }
- }
- return false
- }
}
/**
@@ -264,11 +185,6 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
-data class SeerrState(
- val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
- val pluginProvided: Boolean = false,
-)
-
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
@@ -289,15 +205,11 @@ data class CurrentSeerr(
val config: SeerrUserConfig,
val serverConfig: PublicSettings,
) {
- val requestMovieEnabled: Boolean
- get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
val request4kMovieEnabled: Boolean
get() =
(serverConfig.movie4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
- val requestTvEnabled: Boolean
- get() = config.hasPermission(SeerrPermission.REQUEST_TV)
val request4kTvEnabled: Boolean
get() =
(serverConfig.series4kEnabled ?: false) &&
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt
deleted file mode 100644
index 8b51ad63..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.github.damontecres.wholphin.services
-
-import com.github.damontecres.wholphin.data.model.HomePageSettings
-import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
-import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
-import com.github.damontecres.wholphin.util.WholphinDispatchers
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.json.decodeFromStream
-import okhttp3.HttpUrl.Companion.toHttpUrl
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okio.IOException
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.exception.ApiClientException
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Service for interacting with the companion plugin
- */
-@Singleton
-class ServerPluginApi
- @Inject
- constructor(
- @param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
- private val api: ApiClient,
- ) {
- private fun createUrl(path: String): String? =
- api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" }
-
- private val json get() = HomeSettingsService.jsonParser
-
- companion object {
- private const val HOME_CONFIG_PATH = "homesettings"
- private const val SEERR_CONFIG_PATH = "seerrsettings"
- }
-
- /**
- * Check if the plugin is installed on the server
- */
- suspend fun checkInstalled(): Boolean =
- withContext(WholphinDispatchers.IO) {
- val url = createUrl("public") ?: return@withContext false
- val request =
- Request
- .Builder()
- .url(url)
- .get()
- .build()
- try {
- return@withContext okHttpClient
- .newCall(request)
- .execute()
- .use { it.isSuccessful }
- } catch (ex: IOException) {
- Timber.w("Could not connect to server plugin: %s", ex.message)
- return@withContext false
- }
- }
-
- private suspend inline fun executeRequest(path: String): T? {
- val url = createUrl(path) ?: return null
- val request = Request(url.toHttpUrl())
- return executeRequest(request)
- }
-
- @OptIn(ExperimentalSerializationApi::class)
- private suspend inline fun executeRequest(request: Request): T? =
- withContext(WholphinDispatchers.IO) {
- okHttpClient.newCall(request).execute().use { res ->
- if (res.isSuccessful) {
- json.decodeFromStream(res.body.byteStream())
- } else if (res.code == 404) {
- Timber.w("Plugin 404: %s", request.url.encodedPath)
- null
- } else {
- throw ApiClientException(res.code.toString() + " " + res.body.string())
- }
- }
- }
-
- /**
- * Get the default home page settings from the plugin
- *
- * @return the home page settings or null if no rows are configured or the server isn't installed
- */
- suspend fun fetchHomePageSettings(): HomePageSettings? =
- executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() }
-
- /**
- * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured
- */
- suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH)
- }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
index 0b9da9f8..09450b34 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -1,27 +1,15 @@
package com.github.damontecres.wholphin.services
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
+import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
-import com.github.damontecres.wholphin.data.model.RelativeMediaStream
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
-import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
-import com.github.damontecres.wholphin.data.model.TrackFlag
-import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.data.model.asRelative
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings
-import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
-import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
-import com.github.damontecres.wholphin.ui.util.ResStringProvider
-import com.github.damontecres.wholphin.ui.util.StringProvider
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
@@ -42,69 +30,50 @@ class StreamChoiceService
@Inject
constructor(
private val serverRepository: ServerRepository,
- private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
+ private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
- suspend fun saveSeriesTrackChoice(
+ suspend fun updateAudio(
dto: BaseItemDto,
- type: MediaStreamType,
- stream: RelativeMediaStream?,
- activation: ActivationFlag = ActivationFlag.ACTIVATED,
- ) {
- val type =
- when (stream?.type ?: type) {
- MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
- MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
- else -> return
- }
- val userId = serverRepository.currentUser!!.rowId
- val newStc =
- buildList {
- dto.parentId
- ?.let { seasonId ->
- SeriesTrackChoice(
- userId = userId,
- itemId = dto.id,
- parentId = seasonId,
- type = type,
- parentType = TrackChoiceParentType.SEASON,
- stream = stream,
- activation = activation,
- )
- }?.let(::add)
- dto.seriesId
- ?.let { seriesId ->
- SeriesTrackChoice(
- userId = userId,
- itemId = dto.id,
- parentId = seriesId,
- type = type,
- parentType = TrackChoiceParentType.SERIES,
- stream = stream,
- activation = activation,
- )
- }?.let(::add)
- }
- Timber.d(
- "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
- dto.id,
- dto.seriesId,
- dto.parentId,
- newStc,
+ audioLang: String,
+ ) = update(dto) {
+ it.copy(
+ audioLanguage = audioLang,
)
- seriesTrackChoiceDao.save(newStc)
}
- suspend fun saveDisabledSeriesTrackChoice(
+ suspend fun updateSubtitles(
dto: BaseItemDto,
- type: MediaStreamType,
- ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
+ subtitleLang: String?,
+ subtitlesDisabled: Boolean,
+ ) = update(dto) {
+ it.copy(
+ subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
+ subtitlesDisabled = subtitlesDisabled,
+ )
+ }
- suspend fun saveOnlyForcedSeriesTrackChoice(
+ suspend fun update(
dto: BaseItemDto,
- type: MediaStreamType,
- ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
+ update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
+ ) {
+ val seriesId = dto.seriesId
+ if (seriesId != null) {
+ val userId = serverRepository.currentUser!!.rowId
+ val currentPlc =
+ playbackLanguageChoiceDao.get(userId, seriesId)
+ ?: PlaybackLanguageChoice(userId, seriesId, dto.id)
+ val newPlc = update.invoke(currentPlc)
+ Timber.v("Saving series PLC: %s", newPlc)
+ playbackLanguageChoiceDao.save(newPlc)
+ }
+ }
+
+ suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
+ dto.seriesId?.let {
+ playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
+ }
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
@@ -135,15 +104,21 @@ class StreamChoiceService
*/
suspend fun chooseAudioStream(
source: MediaSourceInfo,
- item: BaseItemDto,
+ seriesId: UUID?,
itemPlayback: ItemPlayback?,
- stc: List?,
+ plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
- ): StreamChoiceResult? {
- val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
+ ): MediaStream? {
+ val plc =
+ plc ?: seriesId?.let {
+ playbackLanguageChoiceDao.get(
+ serverRepository.currentUser!!.rowId,
+ it,
+ )
+ }
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
- chooseAudioStream(candidates, itemPlayback, stc, prefs)
+ chooseAudioStream(candidates, itemPlayback, plc, prefs)
}
}
@@ -153,70 +128,59 @@ class StreamChoiceService
fun chooseAudioStream(
candidates: List,
itemPlayback: ItemPlayback?,
- stc: List,
+ playbackLanguageChoice: PlaybackLanguageChoice?,
prefs: UserPreferences,
- ): StreamChoiceResult =
+ ): MediaStream? =
if (itemPlayback?.audioIndexEnabled == true) {
- candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
- StreamChoiceReason.Item(
- itemPlayback,
- )
- } else if (stc.isNotEmpty()) {
- val first = stc.first()
- val result = scoreStreams(candidates, first)
- if (result == null) {
- null with StreamChoiceReason.from(first)
- } else if (result.isEmpty() && stc.size > 1) {
- // SeriesTrackChoice did not apply to any streams, but there are more options
- chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
- } else if (result.isEmpty()) {
- // SeriesTrackChoice did not apply to any streams, so use regular selection logic
- Timber.v("No audio STC applied")
- chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
- } else {
- // Otherwise, use the best scored stream
- Timber.v("Using audio STC from %s", first.parentId)
- result.first().stream with StreamChoiceReason.from(first, result)
- }
+ candidates.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
- val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
- val stream =
- if (audioLanguage.isNotNullOrBlank()) {
- val sorted =
- candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
- sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
- ?: sorted.firstOrNull { it.language == audioLanguage }
- ?: sorted.firstOrNull { it.isDefault }
- ?: sorted.firstOrNull()
- } else {
- candidates.firstOrNull { it.isDefault }
- ?: candidates.firstOrNull()
- }
- stream with StreamChoiceReason.UserPreferences
+ val seriesLang =
+ playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
+ // If the user has chosen a different language for the series, prefer that
+ val audioLanguage =
+ seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
+ if (audioLanguage.isNotNullOrBlank()) {
+ val sorted =
+ candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
+ sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
+ ?: sorted.firstOrNull { it.language == audioLanguage }
+ ?: sorted.firstOrNull { it.isDefault }
+ ?: sorted.firstOrNull()
+ } else {
+ candidates.firstOrNull { it.isDefault }
+ ?: candidates.firstOrNull()
+ }
}
/**
* Returns the subtitle stream that should play
*/
suspend fun chooseSubtitleStream(
- item: BaseItemDto,
source: MediaSourceInfo,
audioStream: MediaStream?,
+ seriesId: UUID?,
itemPlayback: ItemPlayback?,
- stc: List?,
+ plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
- ): StreamChoiceResult? =
- source.mediaStreams?.letNotEmpty { streams ->
+ ): MediaStream? {
+ val plc =
+ plc ?: seriesId?.let {
+ playbackLanguageChoiceDao.get(
+ serverRepository.currentUser!!.rowId,
+ it,
+ )
+ }
+ return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
- val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
chooseSubtitleStream(
audioStream?.language,
candidates,
itemPlayback,
- stc,
+ plc,
prefs,
)
}
+ }
/**
* Resolves ONLY_FORCED to an actual subtitle track index.
@@ -225,7 +189,7 @@ class StreamChoiceService
suspend fun resolveSubtitleIndex(
source: MediaSourceInfo,
audioStreamIndex: Int?,
- item: BaseItemDto,
+ seriesId: UUID?,
subtitleIndex: Int,
prefs: UserPreferences,
): Int? =
@@ -239,17 +203,17 @@ class StreamChoiceService
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser!!.rowId,
- itemId = item.id,
+ itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
subtitleIndex = TrackIndex.ONLY_FORCED,
)
chooseSubtitleStream(
- item = item,
source = source,
audioStream = audioStream,
+ seriesId = seriesId,
itemPlayback = itemPlayback,
- stc = null,
+ plc = null,
prefs = prefs,
- )?.stream?.index
+ )?.index
}
/**
@@ -259,81 +223,47 @@ class StreamChoiceService
audioStreamLang: String?,
candidates: List,
itemPlayback: ItemPlayback?,
- stc: List,
+ playbackLanguageChoice: PlaybackLanguageChoice?,
prefs: UserPreferences,
- ): StreamChoiceResult {
- if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
- if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
- return null with StreamChoiceReason.Item(itemPlayback)
- } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
- val subtitleLanguage =
- getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
- // Client-side manual override: User selected "Only Forced" in player menu
- return findForcedTrack(
- candidates,
- subtitleLanguage,
- audioStreamLang,
- ) with StreamChoiceReason.Item(itemPlayback)
- } else {
- return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
- StreamChoiceReason.Item(itemPlayback)
- }
- } else if (stc.isNotEmpty()) {
- val first = stc.first()
- return when (first.activation) {
- ActivationFlag.DISABLED -> {
- null with StreamChoiceReason.from(first)
- }
-
- ActivationFlag.ONLY_FORCED -> {
- val subtitleLanguage =
- getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
- val stream =
- findForcedTrack(
- candidates,
- subtitleLanguage,
- audioStreamLang,
- )
- if (stream != null) {
- stream with StreamChoiceReason.from(first)
- } else {
- handleSubtitleStreamChoiceResult(
- result = emptyList(),
- audioStreamLang = audioStreamLang,
- candidates = candidates,
- itemPlayback = itemPlayback,
- stc = stc,
- prefs = prefs,
- )
- }
- }
-
- ActivationFlag.ACTIVATED -> {
- val result =
- scoreStreams(candidates, first)
- ?: throw IllegalStateException("scoreStreams should not return null for activated")
- handleSubtitleStreamChoiceResult(
- result = result,
- audioStreamLang = audioStreamLang,
- candidates = candidates,
- itemPlayback = itemPlayback,
- stc = stc,
- prefs = prefs,
- )
- }
- }
+ ): MediaStream? {
+ if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
+ return null
+ }
+ val seriesLang =
+ playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
+ val subtitleLanguage =
+ seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
+ if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
+ // Client-side manual override: User selected "Only Forced" in player menu
+ return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
+ } else if (itemPlayback?.subtitleIndexEnabled == true) {
+ return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
- val subtitleLanguage =
- getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val subtitleMode =
- when (prefs.userPreferences?.subtitleMode) {
- SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
- SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
- SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
- SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
- SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
- SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
- null -> SubtitlePlaybackMode.DEFAULT
+ when {
+ playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
+ // User has chosen a series level subtitle language, so override their normal
+ // subtitle mode to display that language
+ SubtitlePlaybackMode.ALWAYS
+ }
+
+ playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
+ // Series level settings disables subtitles
+ SubtitlePlaybackMode.NONE
+ }
+
+ else -> {
+ // Fallback to the user's preference
+ when (prefs.userPreferences?.subtitleMode) {
+ SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
+ SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
+ SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
+ SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
+ SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
+ SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
+ null -> SubtitlePlaybackMode.DEFAULT
+ }
+ }
} ?: SubtitlePlaybackMode.DEFAULT
val candidates =
candidates
@@ -347,104 +277,73 @@ class StreamChoiceService
}.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
- val stream =
- when (subtitleMode) {
- SubtitlePlaybackMode.ALWAYS -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull {
- // Prefer non-forced first
- !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
- } ?: candidates.firstOrNull {
- it.language.equalsLangOrUnknown(subtitleLanguage)
- }
- } else {
- candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
+ return when (subtitleMode) {
+ SubtitlePlaybackMode.ALWAYS -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull {
+ // Prefer non-forced first
+ !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
+ } ?: candidates.firstOrNull {
+ it.language.equalsLangOrUnknown(subtitleLanguage)
}
- }
-
- SubtitlePlaybackMode.ONLY_FORCED -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
- ?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
- } else {
- candidates.firstOrNull { it.isForced }
- }
- }
-
- SubtitlePlaybackMode.SMART -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- val audioLanguage = userConfig?.audioLanguagePreference
- if (
- // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
- (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
- // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
- (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
- ) {
- candidates.firstOrNull { it.language == subtitleLanguage }
- ?: candidates.firstOrNull { it.language.isUnknown }
- } else {
- // Otherwise, show forced subtitles in preferred lang
- candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
- ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
- }
- } else {
- candidates.firstOrNull { it.isDefault }
- }
- }
-
- SubtitlePlaybackMode.DEFAULT -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
- ?: candidates.firstOrNull { it.isDefault || it.isForced }
- } else {
- candidates.firstOrNull { it.isDefault || it.isForced }
- }
- }
-
- SubtitlePlaybackMode.NONE -> {
- null
+ } else {
+ candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
}
- return stream with StreamChoiceReason.UserPreferences
+
+ SubtitlePlaybackMode.ONLY_FORCED -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
+ ?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
+ } else {
+ candidates.firstOrNull { it.isForced }
+ }
+ }
+
+ SubtitlePlaybackMode.SMART -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ val audioLanguage = userConfig?.audioLanguagePreference
+ if (
+ // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
+ (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
+ // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
+ (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
+ ) {
+ candidates.firstOrNull { it.language == subtitleLanguage }
+ ?: candidates.firstOrNull { it.language.isUnknown }
+ } else {
+ // Otherwise, show forced subtitles in preferred lang
+ candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
+ ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
+ }
+ } else {
+ candidates.firstOrNull { it.isDefault }
+ }
+ }
+
+ SubtitlePlaybackMode.DEFAULT -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
+ ?: candidates.firstOrNull { it.isDefault || it.isForced }
+ } else {
+ candidates.firstOrNull { it.isDefault || it.isForced }
+ }
+ }
+
+ SubtitlePlaybackMode.NONE -> {
+ null
+ }
+ }
}
}
- /**
- * Recursively calls [chooseSubtitleStream] based on [result] and [stc]
- */
- private fun handleSubtitleStreamChoiceResult(
- result: List,
- audioStreamLang: String?,
- candidates: List,
- itemPlayback: ItemPlayback?,
- stc: List,
- prefs: UserPreferences,
- ): StreamChoiceResult {
- val first = stc.first()
- return if (result.isEmpty() && stc.size > 1) {
- // SeriesTrackChoice did not apply to any streams, but there are more options
- chooseSubtitleStream(
- audioStreamLang,
- candidates,
- itemPlayback,
- stc.subList(1, stc.size),
- prefs,
- )
- } else if (result.isEmpty()) {
- // SeriesTrackChoice did not apply to any streams, so use regular selection logic
- Timber.v("No subtitle STC applied")
- chooseSubtitleStream(
- audioStreamLang,
- candidates,
- itemPlayback,
- emptyList(),
- prefs,
- )
- } else {
- // Otherwise, use the best scored stream
- Timber.v("Using subtitle STC from %s", first.parentId)
- result.first().stream with StreamChoiceReason.from(first, result)
- }
+ /** Returns true if the track is forced (via metadata flag or title patterns). */
+ private fun isForcedOrSigns(track: MediaStream): Boolean {
+ if (track.isForced) return true
+ val title = track.title ?: track.displayTitle ?: return false
+ return title.contains("forced", ignoreCase = true) ||
+ title.contains("signs", ignoreCase = true) ||
+ title.contains("songs", ignoreCase = true)
}
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
@@ -468,92 +367,19 @@ class StreamChoiceService
// 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
}
-
- suspend fun getSeriesTrackChoices(
- seriesId: UUID?,
- seasonId: UUID?,
- type: SeriesTrackChoiceType,
- ): List {
- val userId = serverRepository.currentUser?.rowId ?: return emptyList()
- return when {
- seriesId != null && seasonId != null -> {
- seriesTrackChoiceDao.get(
- userId = userId,
- seasonId = seasonId,
- seriesId = seriesId,
- type = type,
- )
- }
-
- seriesId != null -> {
- seriesTrackChoiceDao.getBySeriesId(
- userId = userId,
- seriesId = seriesId,
- type = type,
- )
- }
-
- seasonId != null -> {
- seriesTrackChoiceDao.getBySeasonId(
- userId = userId,
- seasonId = seasonId,
- type = type,
- )
- }
-
- else -> {
- emptyList()
- }
- }
- }
-
- suspend fun getSeriesTrackChoices(
- item: BaseItemDto,
- type: SeriesTrackChoiceType,
- ): List = getSeriesTrackChoices(item.seriesId, item.parentId, type)
-
- suspend fun getSeriesTrackChoices(
- item: BaseItemDto,
- type: MediaStreamType,
- ): List =
- getSeriesTrackChoices(
- seriesId = item.seriesId,
- seasonId = item.parentId,
- type =
- when (type) {
- MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
- MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
- else -> return emptyList()
- },
- )
}
private val String?.isUnknown: Boolean
get() =
- this.isNullOrBlank() ||
+ this == null ||
this.equals("unknown", true) ||
this.equals("und", true) ||
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)
-private val String?.isNotUnknown: Boolean
- get() = !this.isUnknown
-
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
-private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true)
-
-/** Returns true if the track is forced (via metadata flag or title patterns). */
-fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track)
-
-fun isSigns(track: MediaStream): Boolean {
- val title = track.title ?: track.displayTitle ?: return false
- return title.contains("forced", ignoreCase = true) ||
- title.contains("signs", ignoreCase = true) ||
- title.contains("songs", ignoreCase = true)
-}
-
/**
* Based on the user's preferences, get their preferred language for audio or subtitles
*
@@ -586,153 +412,3 @@ fun getPreferredLanguage(
else -> pref
}?.takeIf { it.isNotNullOrBlank() }
}
-
-/**
- * Scores streams based the user's explicit [SeriesTrackChoice]
- *
- * @return sorted list of preferred streams or null if stream should be disabled
- */
-fun scoreStreams(
- streams: List,
- choice: SeriesTrackChoice,
-): List? {
- if (choice.activation == ActivationFlag.DISABLED) {
- return null
- }
- val streams =
- streams.filter {
- if (
- (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) &&
- !isForcedOrSigns(it)
- ) {
- // Filter out non-forced tracks if the user only wants forced
- return@filter false
- }
- if (choice.language.isNotUnknown &&
- !choice.language.equalsLangExact(it.language)
- ) {
- // Filter out languages that do not match
- return@filter false
- }
- // Filter by type
- choice.type.mediaStreamType == it.type
- }
- val scored =
- streams
- .asRelative()
- .map { s ->
- var score = 0
-
- // Prefer exact language match over unknown
- if (choice.language.equalsLangExact(s.language)) score += 10_000
-
- if (s.isForced && choice.has(TrackFlag.FORCED)) {
- score += 1_000
- }
- if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) {
- score += 500
- }
- if (s.isDefault && choice.has(TrackFlag.DEFAULT)) {
- score += 500
- }
-
- if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) {
- score += 500
- } else if (choice.has(TrackFlag.SDH) && s.isForced) {
- // If user wants SDH, prefer non-forced "regular" over forced
- score -= 10
- }
-
- if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) {
- score += 100
- }
-
- if (s.relativeIndex == choice.relativeTrackIndex) {
- score += 100
- }
- if (s.stream.title == choice.title) {
- score += 10
- }
-
- if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) {
- score += 100
- } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) {
- score += 50
- }
-
- if (s.isDefault) {
- // All else being equal, prefer the default track
- score += 1
- }
- ScoredMediaStream(score, s.stream)
- }.sortedByDescending { it.score }
- return scored
-}
-
-data class StreamChoiceResult(
- val stream: MediaStream?,
- val reason: StreamChoiceReason,
-)
-
-data class ScoredMediaStream(
- val score: Int,
- val stream: MediaStream,
-)
-
-private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason)
-
-/**
- * Why a particular [MediaStream] was chosen
- */
-sealed interface StreamChoiceReason {
- val description: StringProvider
- val scores: List get() = emptyList()
-
- data object Unknown : StreamChoiceReason {
- override val description: StringProvider = ResStringProvider(R.string.unknown)
- }
-
- data object UserPreferences : StreamChoiceReason {
- override val description: StringProvider =
- ResStringProvider(R.string.profile_specific_settings)
- }
-
- data class Series(
- val stc: SeriesTrackChoice,
- override val scores: List,
- ) : StreamChoiceReason {
- override val description: StringProvider =
- ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
- }
-
- data class Season(
- val stc: SeriesTrackChoice,
- override val scores: List,
- ) : StreamChoiceReason {
- override val description: StringProvider =
- ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER)
- }
-
- data class Item(
- val itemPlayback: ItemPlayback,
- ) : StreamChoiceReason {
- override val description: StringProvider =
- ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER)
- }
-
- companion object {
- fun from(
- stc: SeriesTrackChoice,
- scores: List = emptyList(),
- ) = when (stc.parentType) {
- TrackChoiceParentType.SERIES -> Series(stc, scores)
- TrackChoiceParentType.SEASON -> Season(stc, scores)
- TrackChoiceParentType.DEPRECATED -> Unknown
- TrackChoiceParentType.UNKNOWN -> Unknown
- }
-
- private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) }
- private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) }
- private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
index e0dd472f..aa64058d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
@@ -5,7 +5,6 @@ import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -64,8 +63,12 @@ class SuggestionsWorker
}
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
- val result = serverRepository.restoreLastSession()
- if (result !is RestoredSession.Success) {
+ var currentUser = serverRepository.current.value
+ if (currentUser == null) {
+ serverRepository.restoreSession(serverId, userId)
+ currentUser = serverRepository.current.value
+ }
+ if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
index b208d663..06115982 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
@@ -22,7 +22,6 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
-import kotlin.coroutines.cancellation.CancellationException
/**
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
@@ -37,7 +36,6 @@ class UserSwitchListener
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
- private val serverPluginApi: ServerPluginApi,
) {
init {
context as AppCompatActivity
@@ -67,78 +65,49 @@ class UserSwitchListener
AppCompatDelegate.setApplicationLocales(localeList)
}
- // Check if plugin is installed, then for home settings & seerr settings
+ // Check for home settings
launchIO {
- val serverPluginInstalled =
- try {
- serverPluginApi.checkInstalled()
- } catch (ex: CancellationException) {
- throw ex
- } catch (ex: Exception) {
- Timber.e(ex, "Error checking for server plugin")
- false
- }
- Timber.i("Server plugin installed: %s", serverPluginInstalled)
- serverRepository.serverPluginInstalled.value = serverPluginInstalled
-
+ homeSettingsService.loadCurrentSettings(user.id)
+ }
+ if (BuildConfig.DISCOVER_ENABLED) {
+ // Check for seerr server
launchIO {
- // Check for home settings
- homeSettingsService.loadCurrentSettings(user)
- }
-
- if (BuildConfig.DISCOVER_ENABLED) {
- // Check for seerr server
- launchIO {
- if (serverPluginInstalled) {
- serverPluginApi.fetchSeerrSettings()?.let {
- Timber.d("Trying seerr auto login")
- val result = seerrServerRepository.usePluginProvidedServer(it)
- if (result) {
- // Successfully used server plugin provided seerr
- Timber.i("Seerr auto login succeeded")
- return@launchIO
+ seerrServerDao
+ .getUsersByJellyfinUser(user.rowId)
+ .lastOrNull()
+ ?.let { seerrUser ->
+ val server =
+ seerrServerDao.getServer(seerrUser.serverId)?.server
+ if (server != null) {
+ Timber.i("Found a seerr user & server")
+ try {
+ seerrApi.update(server.url, seerrUser.credential)
+ val userConfig =
+ if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
+ seerrLogin(
+ seerrApi.api,
+ seerrUser.authMethod,
+ seerrUser.username,
+ seerrUser.password,
+ )
+ } else {
+ seerrApi.api.usersApi.authMeGet()
+ }
+ seerrServerRepository.set(
+ server,
+ seerrUser,
+ userConfig,
+ )
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Error logging into %s",
+ server.url,
+ )
+ seerrServerRepository.error(server, seerrUser, ex)
}
}
}
- Timber.v("Checking for existing configured seerr server")
- // Either no server plugin seerr or there was an issue with it
- seerrServerDao
- .getUsersByJellyfinUser(user.rowId)
- .lastOrNull()
- ?.let { seerrUser ->
- val server =
- seerrServerDao.getServer(seerrUser.serverId)?.server
- if (server != null) {
- Timber.i("Found a seerr user & server")
- try {
- seerrApi.update(server.url, seerrUser.credential)
- val userConfig =
- if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
- seerrLogin(
- seerrApi.api,
- seerrUser.authMethod,
- seerrUser.username,
- seerrUser.password,
- )
- } else {
- seerrApi.api.usersApi.authMeGet()
- }
- seerrServerRepository.set(
- server,
- seerrUser,
- userConfig,
- )
- } catch (ex: Exception) {
- Timber.w(
- ex,
- "Error logging into %s",
- server.url,
- )
- seerrServerRepository.error(server, seerrUser, ex)
- }
- }
- }
- }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
index 3a114017..06607cf9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -4,8 +4,6 @@ import android.content.Context
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.MostRecentServerProvider
-import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
@@ -190,12 +188,6 @@ object AppModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
-
- @Provides
- @Singleton
- fun mostRecentServerProvider(
- @ApplicationContext context: Context,
- ): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
}
@Module
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
index 78270a9b..29688664 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
@@ -18,7 +18,6 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@@ -80,10 +79,6 @@ object DatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
- @Provides
- @Singleton
- fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
-
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
index a4d73caa..d3dbf4c7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -19,7 +19,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
@@ -71,8 +70,12 @@ class TvProviderWorker
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
- val result = serverRepository.restoreLastSession()
- if (result !is RestoredSession.Success) {
+ var currentUser = serverRepository.current.value
+ if (currentUser == null) {
+ serverRepository.restoreSession(serverId, userId)
+ currentUser = serverRepository.current.value
+ }
+ if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
index a284ef0b..f774a0da 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
@@ -8,9 +8,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
-import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT
-import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
-import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@@ -208,13 +205,6 @@ fun formatBytes(
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
-fun formatMaxBitrate(bitrate: Long) =
- if (bitrate < MEGA_BIT) {
- "${bitrate / 1024} kbps"
- } else {
- "${bitrate / MEGA_BIT} Mbps"
- }
-
@get:StringRes
val MediaSegmentType.stringRes: Int
get() =
@@ -310,55 +300,3 @@ fun Resources.formatDuration(duration: Duration): String =
else -> getString(R.string.duration_seconds, seconds)
}
}
-
-fun Resources.serverSupportedMessage(
- serverVersionSupported: ServerVersionSupported,
- serverVersion: ServerVersion?,
-): String? {
- val errorMsg =
- when (serverVersionSupported) {
- ServerVersionSupported.SUPPORTED -> {
- null
- }
-
- ServerVersionSupported.NOT_SUPPORTED,
- ServerVersionSupported.UNKNOWN,
- -> {
- getString(R.string.server_version_not_supported)
- }
-
- ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
- getString(R.string.server_version_not_supported_future)
- }
- }
- return errorMsg?.let {
- val msg =
- if (serverVersion != null) {
- "$it: $serverVersion"
- } else {
- it + ": " + getString(R.string.unknown)
- }
- msg + "\n" +
- when (serverVersionSupported) {
- ServerVersionSupported.SUPPORTED -> {
- ""
- }
-
- ServerVersionSupported.NOT_SUPPORTED,
- ServerVersionSupported.UNKNOWN,
- -> {
- getString(
- R.string.server_version_required,
- WholphinApplication.minimumServerVersion.toString(),
- )
- }
-
- ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
- getString(
- R.string.server_version_required,
- WholphinApplication.newMinimumServerVersion.toString(),
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index af67f12c..1d70553e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -44,6 +44,7 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader
+import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
@@ -52,18 +53,19 @@ import kotlin.time.Duration
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun CollectionFolderViewScope.CollectionFolderGrid(
+fun CollectionFolderGrid(
preferences: UserPreferences,
+ collectionType: CollectionType?,
focusedItem: BaseItem?,
items: List,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
+ viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
- cardContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
@@ -97,8 +99,18 @@ fun CollectionFolderViewScope.CollectionFolderGrid(
modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition,
positionCallback = positionCallback,
- cardContent = { details ->
- cardContent.invoke(this@CollectionFolderGrid, details)
+ cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
+ GridCard(
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ imageContentScale = viewOptions.contentScale.scale,
+ imageAspectRatio = viewOptions.aspectRatio.ratio,
+ imageType = viewOptions.imageType,
+ showTitle = viewOptions.showTitles,
+ fillWidth = widthPx,
+ modifier = mod,
+ )
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
@@ -198,10 +210,10 @@ val CollectionType.baseItemKinds: List
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
}
- CollectionType.PHOTOS,
- CollectionType.HOMEVIDEOS,
- -> {
- listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
+ CollectionType.HOMEVIDEOS -> {
+ listOf(BaseItemKind.VIDEO)
+ listOf(BaseItemKind.PHOTO)
+ listOf(BaseItemKind.PHOTO_ALBUM)
}
CollectionType.MUSIC -> {
@@ -224,6 +236,10 @@ val CollectionType.baseItemKinds: List
listOf(BaseItemKind.PLAYLIST)
}
+ CollectionType.PHOTOS -> {
+ listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
+ }
+
CollectionType.LIVETV -> {
listOf(
BaseItemKind.TV_CHANNEL,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
index 57ff8957..967ddb07 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
@@ -28,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -60,7 +59,6 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
-import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse
@@ -77,7 +75,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.extensions.ticks
@Composable
-fun CollectionFolderViewScope.CollectionFolderList(
+fun CollectionFolderList(
preferences: UserPreferences,
collectionType: CollectionType?,
focusedItem: BaseItem?,
@@ -86,10 +84,10 @@ fun CollectionFolderViewScope.CollectionFolderList(
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
+ viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
- itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
@@ -155,8 +153,10 @@ fun CollectionFolderViewScope.CollectionFolderList(
).padding(8.dp),
)
}
- this@CollectionFolderList.CollectionFolderListContent(
+ CollectionFolderContent(
items = items,
+ viewOptions = viewOptions,
+ collectionType = collectionType,
listState = listState,
position = position,
positionFocusRequester = positionFocusRequester,
@@ -167,7 +167,6 @@ fun CollectionFolderViewScope.CollectionFolderList(
},
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
- itemContent = itemContent,
modifier =
Modifier
.weight(1f)
@@ -267,43 +266,44 @@ fun CollectionFolderListDetails(
}
@Composable
-fun CollectionFolderViewScope.CollectionFolderListContent(
+fun CollectionFolderContent(
items: List,
+ viewOptions: ViewOptions,
+ collectionType: CollectionType?,
listState: LazyListState,
position: Int,
positionFocusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onPositionChange: (Int) -> Unit,
- itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
) {
- val currentPosition by rememberUpdatedState(position)
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
modifier = modifier.focusRestorer(positionFocusRequester),
) {
itemsIndexed(items) { index, item ->
- val details =
- remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
- GridItemDetails(
- item = item,
- index = index,
- onClick = { item?.let { onClickItem.invoke(index, item) } },
- onLongClick = { item?.let { onLongClickItem.invoke(index, item) } },
- widthPx = -1,
- mod =
- Modifier
- .onFocusChanged {
- if (it.isFocused) onPositionChange.invoke(index)
- }.ifElse(
- index == currentPosition,
- Modifier.focusRequester(positionFocusRequester),
- ),
- )
+ val onClick: () -> Unit =
+ remember(index, item) {
+ { item?.let { onClickItem.invoke(index, item) } }
}
- itemContent.invoke(this@CollectionFolderListContent, details)
+ val onLongClick: () -> Unit =
+ remember(index, item) {
+ { item?.let { onLongClickItem.invoke(index, item) } }
+ }
+ CollectionFolderListItem(
+ item = item,
+ dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
+ collectionType = collectionType,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier =
+ Modifier
+ .onFocusChanged {
+ if (it.isFocused) onPositionChange.invoke(index)
+ }.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)),
+ )
}
}
}
@@ -522,12 +522,9 @@ fun ListItemAlbum(
}
val supportingContent =
remember(item, dense) {
- // Album artist is already shown as the overline
- val subtitle =
- if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle
- if (!dense && subtitle != null) {
+ if (!dense && item?.subtitle != null) {
@Composable {
- Text(subtitle)
+ Text(item.subtitle ?: "")
}
} else {
null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
index 8445dfa9..52918e57 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
@@ -54,25 +54,20 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
-import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
-import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
@@ -94,14 +89,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
-import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@@ -153,9 +146,6 @@ class CollectionFolderViewModel
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
val state: StateFlow = _state
- private val artistSource: ArtistSource
- get() = state.value.viewOptions.artistSource
-
var position: Int
get() = savedStateHandle.get("position") ?: 0
set(value) {
@@ -178,25 +168,15 @@ class CollectionFolderViewModel
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
libraryDisplayInfoDao.getItem(user, id)
}
- val ancestorDisplayInfo =
- if (libraryDisplayInfo?.viewOptions == null && item != null) {
- getAncestorDisplayInfo(item)
- } else {
- null
- }
_state.update {
it.copy(
- viewOptions =
- libraryDisplayInfo?.viewOptions
- ?: ancestorDisplayInfo?.viewOptions
- ?: defaultViewOptions,
+ viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
}
val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) {
libraryDisplayInfo?.sortAndDirection
- ?: ancestorDisplayInfo?.sortAndDirection
} else {
null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
@@ -252,25 +232,6 @@ class CollectionFolderViewModel
}
}
- /**
- * For a sub-folder, returns the saved display info of the closest ancestor that has one
- */
- private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? {
- if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) {
- return null
- }
- val user = serverRepository.currentUser ?: return null
- return try {
- api.libraryApi
- .getAncestors(item.id, user.id)
- .content
- .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) }
- } catch (ex: Exception) {
- Timber.w(ex, "Error getting ancestors for %s", item.id)
- null
- }
- }
-
private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = state.value.filter,
newSort: SortAndDirection = state.value.sortAndDirection,
@@ -296,17 +257,7 @@ class CollectionFolderViewModel
override fun saveViewOptions(viewOptions: ViewOptions) {
position = 0
- val artistSourceChanged = viewOptions.artistSource != artistSource
_state.update { it.copy(viewOptions = viewOptions) }
- if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) {
- loadResults(
- true,
- state.value.sortAndDirection,
- recursive,
- state.value.filter,
- useSeriesForPrimary,
- )
- }
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showBackdrop) {
@@ -318,19 +269,11 @@ class CollectionFolderViewModel
override fun onClickRandom() {
viewModelScope.launchIO {
try {
- val state = state.value
- val list = state.items.successValue as? BlockingList
- if (list != null) {
- goToRandom(
- navigateTo = ::navigateTo,
- parentId = itemId,
- filter = state.filter,
- sortAndDirection = state.sortAndDirection,
- recursive = recursive,
- list = list,
- )
- } else {
- Timber.w("state.value.items is not successful: %s", state.items)
+ val random =
+ (state.value.items.successValue as? BlockingList)?.randomBlocking()
+ Timber.d("Got random item: %s", random?.id)
+ random?.destination()?.let {
+ navigateTo(random.destination())
}
} catch (ex: CancellationException) {
throw ex
@@ -456,27 +399,13 @@ class CollectionFolderViewModel
}
GetItemsFilterOverride.ARTIST -> {
- when (artistSource) {
- ArtistSource.ARTIST -> {
- ApiRequestPager(
- api,
- createGetArtistsRequest(filter),
- GetArtistsHandler,
- viewModelScope,
- useSeriesForPrimary = useSeriesForPrimary,
- )
- }
-
- ArtistSource.ALBUM_ARTIST -> {
- ApiRequestPager(
- api,
- createGetAlbumArtistsRequest(filter),
- GetAlbumArtistsHandler,
- viewModelScope,
- useSeriesForPrimary = useSeriesForPrimary,
- )
- }
- }
+ ApiRequestPager(
+ api,
+ createGetArtistsRequest(filter),
+ GetArtistsHandler,
+ viewModelScope,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
}
}
@@ -495,17 +424,6 @@ class CollectionFolderViewModel
)
}
- private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest {
- val item = state.value.item.successValue
- return filter.applyTo(
- GetAlbumArtistsRequest(
- parentId = item?.id,
- enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
- fields = SlimItemFields,
- ),
- )
- }
-
private fun createGetItemsRequest(
sortAndDirection: SortAndDirection,
recursive: Boolean,
@@ -577,37 +495,18 @@ class CollectionFolderViewModel
val filter = state.value.filter
when (filter.override) {
GetItemsFilterOverride.ARTIST -> {
- when (artistSource) {
- ArtistSource.ARTIST -> {
- GetArtistsHandler.countMatching(
- api = api,
- request =
- createGetArtistsRequest(filter).copy(
- enableImageTypes = null,
- fields = null,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- enableUserData = false,
- ),
- )
- }
-
- ArtistSource.ALBUM_ARTIST -> {
- GetAlbumArtistsHandler.countMatching(
- api = api,
- request =
- createGetAlbumArtistsRequest(filter).copy(
- enableImageTypes = null,
- fields = null,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- enableUserData = false,
- ),
- )
- }
- }
+ GetArtistsHandler.countMatching(
+ api = api,
+ request =
+ createGetArtistsRequest(filter).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ enableUserData = false,
+ ),
+ )
}
// GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
@@ -914,33 +813,6 @@ fun CollectionFolderViewContent(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
filterOptions: List> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
- gridCardContent: @Composable CollectionFolderViewScope.(
- GridItemDetails,
- ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
- GridCard(
- item = item,
- onClick = onClick,
- onLongClick = onLongClick,
- imageContentScale = viewOptions.contentScale.scale,
- imageAspectRatio = viewOptions.aspectRatio.ratio,
- imageType = viewOptions.imageType,
- showTitle = viewOptions.showTitles,
- fillWidth = widthPx,
- modifier = mod,
- )
- },
- listItemContent: @Composable CollectionFolderViewScope.(
- GridItemDetails,
- ) -> Unit = { (item, index, onClick, onLongClick, _, mod) ->
- CollectionFolderListItem(
- item = item,
- dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
- collectionType = collectionType,
- onClick = onClick,
- onLongClick = onLongClick,
- modifier = mod,
- )
- },
) {
var position by rememberInt(savedPosition)
@@ -1103,41 +975,9 @@ fun CollectionFolderViewContent(
focusRequester?.tryRequestFocus()
clickedRandom = false
}
- val scope =
- object : CollectionFolderViewScope {
- override val viewOptions: ViewOptions
- get() = state.viewOptions
- override val collectionType: CollectionType?
- get() = item?.data?.collectionType
- override val sortAndDirection: SortAndDirection
- get() = state.sortAndDirection
- override val collectionSize: Int
- get() = pager.data.size
- }
-
Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) {
- scope.CollectionFolderGrid(
- preferences = preferences,
- initialPosition = savedPosition,
- items = pager.data,
- sortAndDirection = state.sortAndDirection,
- modifier = Modifier.fillMaxSize(),
- gridFocusRequester = gridFocusRequester,
- onClickItem = gridActions.onClickItem,
- onLongClickItem = gridActions.onLongClickItem!!,
- positionCallback = { columns, pos ->
- showHeader = pos < columns
- position = pos
- positionCallback?.invoke(columns, pos)
- },
- letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
- onClickPlay = gridActions.onClickPlayRemoteButton!!,
- focusedItem = focusedItem,
- cardContent = gridCardContent,
- )
- } else {
- scope.CollectionFolderList(
+ CollectionFolderGrid(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = savedPosition,
@@ -1153,9 +993,30 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
+ viewOptions = state.viewOptions,
+ onClickPlay = gridActions.onClickPlayRemoteButton!!,
+ focusedItem = focusedItem,
+ )
+ } else {
+ CollectionFolderList(
+ preferences = preferences,
+ collectionType = item?.data?.collectionType,
+ initialPosition = savedPosition,
+ items = pager.data,
+ sortAndDirection = state.sortAndDirection,
+ modifier = Modifier.fillMaxSize(),
+ gridFocusRequester = gridFocusRequester,
+ onClickItem = gridActions.onClickItem,
+ onLongClickItem = gridActions.onLongClickItem!!,
+ positionCallback = { columns, pos ->
+ showHeader = pos < columns
+ position = pos
+ positionCallback?.invoke(columns, pos)
+ },
+ letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
+ viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
- itemContent = listItemContent,
)
}
androidx.compose.animation.AnimatedVisibility(
@@ -1186,7 +1047,6 @@ fun CollectionFolderViewContent(
ViewOptionsDialog(
viewOptions = state.viewOptions,
defaultViewOptions = defaultViewOptions,
- showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST,
onDismissRequest = {
showViewOptions = false
viewActions.saveViewOptions(state.viewOptions)
@@ -1195,13 +1055,3 @@ fun CollectionFolderViewContent(
)
}
}
-
-/**
- * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
- */
-interface CollectionFolderViewScope {
- val viewOptions: ViewOptions
- val collectionType: CollectionType?
- val sortAndDirection: SortAndDirection
- val collectionSize: Int
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
index f87010d9..10a41af3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
@@ -209,12 +209,7 @@ fun ContextMenu(
favorite = item.favorite,
seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
- canClearChosenStreams =
- chosenStreams.let {
- it?.itemPlayback != null ||
- it?.audioStc?.isNotEmpty() == true ||
- it?.subtitleStc?.isNotEmpty() == true
- },
+ canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
contextMenu = contextMenu,
actions = actions,
onChooseVersion = {
@@ -240,11 +235,11 @@ fun ContextMenu(
resources = resources,
streams = source.mediaStreams.orEmpty(),
type = type,
- streamChoice =
+ currentIndex =
if (type == MediaStreamType.AUDIO) {
- chosenStreams?.audioStream
+ chosenStreams?.audioStream?.index
} else {
- chosenStreams?.subtitleStream
+ chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex ->
actions.onChooseTracks.invoke(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
index 76b7dc77..d7bd9951 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
@@ -64,7 +64,6 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
-import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@@ -231,35 +230,28 @@ fun DialogPopup(
onDismissRequest = onDismissRequest,
properties = properties,
) {
- val elevatedContainerColor =
- MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
DialogPopupContent(
title = title,
dialogItems = dialogItems,
waiting = waiting,
onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick,
+ elevation = elevation,
modifier =
- Modifier
- .onKeyEvent { event ->
- val code = event.nativeKeyEvent.keyCode
- if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
- code in
- setOf(
- KeyEvent.KEYCODE_ENTER,
- KeyEvent.KEYCODE_DPAD_CENTER,
- KeyEvent.KEYCODE_NUMPAD_ENTER,
- )
- ) {
- waiting = false
- }
- false
- }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
- .graphicsLayer {
- this.clip = true
- this.shape = RoundedCornerShape(28.0.dp)
- }.drawBehind { drawRect(color = elevatedContainerColor) }
- .padding(PaddingValues(24.dp)),
+ Modifier.onKeyEvent { event ->
+ val code = event.nativeKeyEvent.keyCode
+ if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
+ code in
+ setOf(
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.KEYCODE_DPAD_CENTER,
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
+ )
+ ) {
+ waiting = false
+ }
+ false
+ },
)
}
}
@@ -273,10 +265,20 @@ fun DialogPopupContent(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
dismissOnClick: Boolean = true,
+ elevation: Dp = 8.dp,
) {
+ val elevatedContainerColor =
+ MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = modifier,
+ modifier =
+ modifier
+ .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
+ .graphicsLayer {
+ this.clip = true
+ this.shape = RoundedCornerShape(28.0.dp)
+ }.drawBehind { drawRect(color = elevatedContainerColor) }
+ .padding(PaddingValues(24.dp)),
) {
Text(
text = title,
@@ -450,14 +452,12 @@ fun ConfirmDialog(
properties: DialogProperties = DialogProperties(),
elevation: Dp = 1.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
- @StringRes cancelStringRes: Int = R.string.cancel,
- @StringRes confirmStringRes: Int = R.string.confirm,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
- ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
+ ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
},
)
@@ -472,8 +472,6 @@ fun ConfirmDialogContent(
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
- @StringRes cancelStringRes: Int = R.string.cancel,
- @StringRes confirmStringRes: Int = R.string.confirm,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -504,11 +502,11 @@ fun ConfirmDialogContent(
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
- stringRes = cancelStringRes,
+ stringRes = R.string.cancel,
onClick = onCancel,
)
TextButton(
- stringRes = confirmStringRes,
+ stringRes = R.string.confirm,
onClick = onConfirm,
)
}
@@ -653,13 +651,12 @@ fun resourceFor(type: MediaStreamType): Int =
fun chooseStream(
resources: Resources,
streams: List,
- streamChoice: StreamChoiceResult?,
+ currentIndex: Int?,
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
): DialogParams {
val filteredStreams = streams.filter { it.type == type }
- val currentIndex = streamChoice?.stream?.index
return DialogParams(
fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
@@ -677,16 +674,6 @@ fun chooseStream(
},
supportingContent = {
},
- trailingContent =
- if (currentIndex == null && streamChoice != null) {
- {
- Text(
- text = streamChoice.reason.description.getString(),
- )
- }
- } else {
- null
- },
onClick = { onClick.invoke(TrackIndex.DISABLED) },
),
)
@@ -732,16 +719,6 @@ fun chooseStream(
supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
},
- trailingContent =
- if (currentIndex == stream.index) {
- {
- Text(
- text = streamChoice.reason.description.getString(),
- )
- }
- } else {
- null
- },
onClick = { onClick.invoke(stream.index) },
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
index 73e9af44..9ac5adc0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
@@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) =
),
),
RecommendedRow(
- title = R.string.top_rated,
+ title = R.string.top_unwatched,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
@@ -69,6 +69,7 @@ private fun getRecommendedRows(parentId: UUID) =
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
+ isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
enableTotalRecordCount = false,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
index b69e1126..9ff0e2c0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
@@ -97,7 +97,6 @@ class StudioViewModel
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
- tag = it.imageTags?.get(ImageType.THUMB),
)
Studio(
id = it.id,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
index 4268f2d9..c5da7565 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
@@ -95,11 +95,7 @@ fun rememberLogoUrl(item: BaseItem?): String? {
item.data.seriesId != null &&
item.data.parentLogoImageTag != null
) {
- imageUrlService.getItemImageUrl(
- itemId = item.data.seriesId!!,
- imageType = ImageType.LOGO,
- tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
- )
+ imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
} else {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
index 94842ef5..083dded6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
@@ -54,8 +54,8 @@ fun VideoStreamDetails(
) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
- chosenStreams?.audioStream?.stream,
- chosenStreams?.subtitleStream?.stream,
+ chosenStreams?.audioStream,
+ chosenStreams?.subtitleStream,
numberOfVersions,
modifier,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
index df923a55..a1237acd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
@@ -55,7 +55,6 @@ fun ViewOptionsDialog(
onDismissRequest: () -> Unit,
onViewOptionsChange: (ViewOptions) -> Unit,
defaultViewOptions: ViewOptions = ViewOptions(),
- showArtistSource: Boolean = false,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@@ -73,11 +72,11 @@ fun ViewOptionsDialog(
window.setDimAmount(0f)
}
val options =
- remember(viewOptions.type, showArtistSource) {
+ remember(viewOptions.type) {
when (viewOptions.type) {
- ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource)
- ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource)
- ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource)
+ ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS
+ ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS
+ ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
}
}
Column(
@@ -131,12 +130,6 @@ fun ViewOptionsDialog(
}
}
-/** Which Jellyfin artists list backs the Music > Artists tab */
-enum class ArtistSource {
- ARTIST,
- ALBUM_ARTIST,
-}
-
/**
* Stores the customizable view changes from the user
*
@@ -153,7 +146,6 @@ data class ViewOptions(
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true,
val type: ViewOptionsType = ViewOptionsType.GRID,
- val extras: ViewOptionsExtras = ViewOptionsExtras.None,
) {
companion object {
val ViewOptionsColumns =
@@ -258,34 +250,12 @@ data class ViewOptions(
valueToIndex = { it.ordinal },
)
- val ViewOptionsArtistSource =
- AppChoicePreference(
- title = R.string.artist_source,
- defaultValue = ArtistSource.ARTIST,
- displayValues = R.array.artist_sources,
- getter = { it.artistSource },
- setter = { viewOptions, value ->
- viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value))
- },
- indexToValue = { ArtistSource.entries[it] },
- valueToIndex = { it.ordinal },
- )
-
val ViewOptionsReset =
AppClickablePreference(
title = R.string.reset,
)
- /**
- * Get the options for a grid [ViewOptions]
- */
- fun buildGridOptions(showArtistSource: Boolean) =
- buildList {
- if (showArtistSource) add(ViewOptionsArtistSource)
- addAll(GRID_OPTIONS)
- }
-
- private val GRID_OPTIONS =
+ val GRID_OPTIONS =
listOf(
ViewOptionsTypePref,
ViewOptionsImageType,
@@ -299,16 +269,7 @@ data class ViewOptions(
ViewOptionsReset,
)
- /**
- * Get the options for a list [ViewOptions]
- */
- fun buildListOptions(showArtistSource: Boolean) =
- buildList {
- if (showArtistSource) add(ViewOptionsArtistSource)
- addAll(LIST_OPTIONS)
- }
-
- private val LIST_OPTIONS =
+ val LIST_OPTIONS =
listOf(
ViewOptionsTypePref,
ViewOptionsDetailHeader,
@@ -377,22 +338,3 @@ val BaseItemKind.defaultViewOptions: ViewOptions
else -> ViewOptionsPoster
}
-
-/**
- * Extras [ViewOptions] info that is context or library specific
- */
-@Serializable
-sealed interface ViewOptionsExtras {
- @Serializable
- data object None : ViewOptionsExtras
-
- @Serializable
- data class MusicLibrary(
- val artistSource: ArtistSource = ArtistSource.ARTIST,
- ) : ViewOptionsExtras
-}
-
-/**
- * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary]
- */
-val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index fb4cdbdd..7c8268b0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -9,16 +9,11 @@ import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.Display
-import android.view.Display.HdrCapabilities
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalGridApi
-import androidx.compose.foundation.layout.Grid
-import androidx.compose.foundation.layout.GridFlow
-import androidx.compose.foundation.layout.GridTrackSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -37,7 +32,6 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
@@ -46,11 +40,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ItemPlaybackDao
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -64,11 +56,9 @@ import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
-import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.Date
-import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@@ -78,18 +68,17 @@ class DebugViewModel
@param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
- val seriesTrackChoiceDao: SeriesTrackChoiceDao,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
- private val deviceProfileService: DeviceProfileService,
) : ViewModel() {
val state = MutableStateFlow(DebugState())
val itemPlaybacks = MutableStateFlow>(emptyList())
val logcat = MutableStateFlow>(emptyList())
- val display: Display by lazy {
+ val supportedModes by lazy {
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
- displayManager.getDisplay(Display.DEFAULT_DISPLAY)
+ val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
+ display.supportedModes.orEmpty()
}
val av1Included by lazy {
@@ -170,7 +159,7 @@ class DebugViewModel
state.update {
it.copy(
deviceInfo = deviceInfoList,
- displayModes = display.supportedModes.map { it.toDisplayMode(display) },
+ displayModes = supportedModes.map { it.toString() },
)
}
}
@@ -180,13 +169,6 @@ class DebugViewModel
itemPlaybacks.value = results
}
}
- viewModelScope.launchIO {
- serverRepository.currentUser?.rowId?.let {
- val results = seriesTrackChoiceDao.getAll(it)
- Timber.v("Got %s SeriesTrackChoice", results.size)
- results.forEach { Timber.v("%s", it) }
- }
- }
viewModelScope.launchIO {
val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat
@@ -224,20 +206,6 @@ class DebugViewModel
audioManager.registerAudioDeviceCallback(callback, null)
addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
}
- viewModelScope.launchIO {
- val hdrSupport =
- buildMap {
- put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10())
- put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus())
- put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision())
- put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL())
- put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8())
- put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10())
- put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus())
- put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision())
- }
- state.update { it.copy(hdrSupport = hdrSupport) }
- }
}
companion object {
@@ -294,9 +262,8 @@ data class LogcatLine(
data class DebugState(
val appInfo: List = emptyList(),
val deviceInfo: List = emptyList(),
- val displayModes: List = emptyList(),
+ val displayModes: List = emptyList(),
val audioInfo: List = emptyList(),
- val hdrSupport: Map = emptyMap(),
)
val AudioDeviceInfo.details: String
@@ -416,7 +383,6 @@ val AudioDeviceInfo.details: String
"productName=$productName, address=$addressStr)"
}
-@OptIn(ExperimentalGridApi::class)
@Composable
fun DebugPage(
preferences: UserPreferences,
@@ -500,53 +466,14 @@ fun DebugPage(
BodyText(it)
}
- SubSectionTitle("Audio Devices")
- state.audioInfo.forEach {
+ SubSectionTitle("Display Modes")
+ state.displayModes.forEach {
BodyText(it)
}
- SubSectionTitle("Display Modes")
- Grid(
- config = {
- repeat(6) { column(GridTrackSize.Auto) }
- flow = GridFlow.Row
- columnGap(8.dp)
- rowGap(8.dp)
- },
- ) {
- EmphasizedBodyText("ID")
- EmphasizedBodyText("Width")
- EmphasizedBodyText("Height")
- EmphasizedBodyText("FPS")
- EmphasizedBodyText("HDR")
- EmphasizedBodyText("Alt FPS")
-
- state.displayModes.forEachIndexed { index, mode ->
- BodyText(mode.id.toString())
- BodyText(mode.width.toString())
- BodyText(mode.height.toString())
- BodyText(mode.fps)
- BodyText(mode.hdr)
- BodyText(mode.altFps)
- }
- }
-
- SubSectionTitle("Detected HDR Support")
- Grid(
- config = {
- column(GridTrackSize.Auto)
- column(GridTrackSize.Auto)
- flow = GridFlow.Row
- columnGap(8.dp)
- rowGap(4.dp)
- },
- ) {
- EmphasizedBodyText("HDR Format")
- EmphasizedBodyText("Supported")
- state.hdrSupport.forEach { (name, supported) ->
- BodyText(name)
- BodyText(supported.toString())
- }
+ SubSectionTitle("Audio Devices")
+ state.audioInfo.forEach {
+ BodyText(it)
}
}
}
@@ -638,29 +565,11 @@ private fun SubSectionTitle(text: String) {
}
@Composable
-private fun BodyText(
- text: String,
- modifier: Modifier = Modifier,
-) {
+private fun BodyText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
- modifier = modifier,
- )
-}
-
-@Composable
-private fun EmphasizedBodyText(
- text: String,
- modifier: Modifier = Modifier,
-) {
- Text(
- text = text,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.SemiBold,
- modifier = modifier,
)
}
@@ -692,45 +601,3 @@ private val THIRD_PARTY_TAGS =
"HardwareConfig:W",
"DfltImageHeaderParser:W",
)
-
-data class DisplayMode(
- val id: Int,
- val width: Int,
- val height: Int,
- val fps: String,
- val hdr: String,
- val altFps: String,
-)
-
-fun Display.Mode.toDisplayMode(display: Display): DisplayMode {
- val fps = String.format(Locale.getDefault(), "%.3f", refreshRate)
- val altFps =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- alternativeRefreshRates.joinToString(", ") {
- String.format(Locale.getDefault(), "%.3f", it)
- }
- } else {
- "N/A"
- }
- val hdr =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) }
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) }
- ?: "None found"
- } else {
- "N/A"
- }
- return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps)
-}
-
-fun hdrTypeToString(type: Int): String =
- when (type) {
- HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
- HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+"
- HdrCapabilities.HDR_TYPE_HLG -> "HLG"
- HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+"
- HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi"
- HdrCapabilities.HDR_TYPE_INVALID -> "Invalid"
- else -> "Unknown ($type)"
- }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
index 5690fadc..adbd9a44 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
@@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
-import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
@@ -701,25 +700,11 @@ class FavoritesViewModel
viewModelScope.launchIO {
try {
collectionStateFor(type)?.let { collectionState ->
- val list =
- collectionState.items.successValue as? BlockingList
- if (list != null) {
- goToRandom(
- navigateTo = ::navigateTo,
- parentId = libraryDisplayItemId(type),
- filter =
- collectionState.filter.copy(
- includeItemTypes = listOf(type),
- ),
- sortAndDirection = collectionState.sortAndDirection,
- recursive = true,
- list = list,
- )
- } else {
- Timber.w(
- "state.value.items is not successful: %s",
- collectionState.items,
- )
+ val random =
+ (collectionState.items.successValue as? BlockingList)?.randomBlocking()
+ Timber.d("Got random item: %s", random?.id)
+ random?.destination()?.let {
+ navigateTo(random.destination())
}
}
} catch (ex: CancellationException) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
index 4e49bd39..1fd7ea9f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
@@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
-import com.github.damontecres.wholphin.services.HomeDataService
+import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService
@@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
+import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -74,7 +75,7 @@ class HomeRowGridViewModel
val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
- private val homeDataService: HomeDataService,
+ private val homeSettingsService: HomeSettingsService,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val navigationManager: NavigationManager,
@@ -127,7 +128,7 @@ class HomeRowGridViewModel
val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val result =
- homeDataService.fetchDataForRow(
+ homeSettingsService.fetchDataForRow(
row = rowConfig,
scope = viewModelScope,
prefs = prefs,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
index 64c48f0e..27212b5e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
@@ -257,7 +257,6 @@ fun PersonPage(
imageUrlService.getItemImageUrl(
itemId = person.id,
imageType = ImageType.PRIMARY,
- tag = person.data.imageTags?.get(ImageType.PRIMARY),
)
}
// A long filmography is hard to navigate as a row, so it is cut at the same
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index 33960f83..b7085db6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -1,26 +1,49 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
@@ -28,49 +51,60 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import androidx.tv.material3.LocalTextStyle
+import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
+import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions
+import com.github.damontecres.wholphin.data.filter.FilterValueOption
+import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService
+import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.PlaylistCreator
+import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.ui.FontAwesome
-import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
-import com.github.damontecres.wholphin.ui.cards.WatchedIcon
-import com.github.damontecres.wholphin.ui.collectLatestIn
+import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
-import com.github.damontecres.wholphin.ui.components.CollectionFolderState
-import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent
-import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
-import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
-import com.github.damontecres.wholphin.ui.components.GridClickActions
-import com.github.damontecres.wholphin.ui.components.ListItemWrapper
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
+import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
+import com.github.damontecres.wholphin.ui.components.FilterByButton
+import com.github.damontecres.wholphin.ui.components.GridTitle
+import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MusicContextActions
import com.github.damontecres.wholphin.ui.components.Optional
+import com.github.damontecres.wholphin.ui.components.OverviewText
+import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.components.TextButton
-import com.github.damontecres.wholphin.ui.components.ViewOptions
-import com.github.damontecres.wholphin.ui.components.ViewOptionsType
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
-import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions
+import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
+import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.formatTime
-import com.github.damontecres.wholphin.ui.gt
+import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
@@ -79,9 +113,13 @@ import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
+import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager
-import com.github.damontecres.wholphin.util.successValue
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -89,62 +127,62 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.playlistsApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
+import kotlin.time.Duration
@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
class PlaylistViewModel
@AssistedInject
constructor(
- private val savedStateHandle: SavedStateHandle,
@ApplicationContext context: Context,
api: ApiClient,
navigationManager: NavigationManager,
musicService: MusicService,
mediaManagementService: MediaManagementService,
+ private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
+ private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
+ private val favoriteWatchManager: FavoriteWatchManager,
+ private val serverReportService: ServerReportService,
+ private val filterOptionCache: FilterOptionCache,
+ private val playlistCreator: PlaylistCreator,
@Assisted itemId: UUID,
- @Assisted private val collectionState: StateFlow,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory
interface Factory {
- fun create(
- itemId: UUID,
- collectionState: StateFlow,
- ): PlaylistViewModel
+ fun create(itemId: UUID): PlaylistViewModel
}
- private val _state = MutableStateFlow(PlaylistDetailsState())
- val state: StateFlow = _state
-
+ val state = MutableStateFlow(PlaylistDetailsState())
val musicState = musicService.state
- var position: Int
- get() = savedStateHandle.get("position") ?: 0
- set(value) {
- savedStateHandle["position"] = value
- }
-
init {
init()
}
override fun init() {
+ state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
try {
+ val playlist =
+ api.userLibraryApi
+ .getItem(itemId)
+ .content
+ .let { BaseItem(it, false) }
val user = serverRepository.currentUser
val canEdit =
user?.let { user ->
@@ -177,12 +215,95 @@ class PlaylistViewModel
false
}
} ?: false
+ state.update {
+ it.copy(
+ playlist = playlist,
+ canEdit = canEdit,
+ )
+ }
- _state.update { it.copy(canEdit = canEdit) }
+ val libraryDisplayInfo =
+ user?.let { user ->
+ libraryDisplayInfoDao.getItem(user, itemId)
+ }
+ val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
+ val sortAndDirection =
+ libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
+ ItemSortBy.DEFAULT,
+ SortOrder.ASCENDING,
+ )
+ loadItems(filter, sortAndDirection).join()
determineMediaType()
} catch (ex: Exception) {
- Timber.e(ex, "Error fetching playlist info for %s", itemId)
-// _state.update { it.copy(item = DataLoadingState.Error(ex)) }
+ Timber.e(ex, "Error fetching playlist %s", itemId)
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ fun loadItems(
+ filter: GetItemsFilter,
+ sortAndDirection: SortAndDirection,
+ ) = viewModelScope.launchIO {
+ backdropService.clearBackdrop()
+ state.update {
+ it.copy(
+ loading = LoadingState.Loading,
+ filterAndSort = FilterAndSort(filter, sortAndDirection),
+ )
+ }
+
+ serverRepository.currentUser?.let { user ->
+ viewModelScope.launchIO {
+ val libraryDisplayInfo =
+ libraryDisplayInfoDao.getItem(user, itemId)?.copy(
+ filter = filter,
+ sort = sortAndDirection.sort,
+ direction = sortAndDirection.direction,
+ )
+ ?: LibraryDisplayInfo(
+ userId = user.rowId,
+ itemId = itemId.toServerString(),
+ sort = sortAndDirection.sort,
+ direction = sortAndDirection.direction,
+ filter = filter,
+ viewOptions = null,
+ )
+ libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
+ }
+
+ val request =
+ filter.applyTo(
+ GetItemsRequest(
+ parentId = itemId,
+ userId = user.id,
+ fields = SlimItemFields,
+ sortBy = listOf(sortAndDirection.sort),
+ sortOrder = listOf(sortAndDirection.direction),
+ ),
+ )
+ try {
+ val pager =
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ viewModelScope,
+ ).init()
+ state.update {
+ it.copy(
+ items = pager,
+ loading = LoadingState.Success,
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching playlist %s", itemId)
+ state.update {
+ it.copy(
+ items = emptyList(),
+ loading = LoadingState.Error(ex),
+ )
+ }
}
}
}
@@ -192,49 +313,77 @@ class PlaylistViewModel
*
* In theory, the server will set the type, but sometimes it doesn't
*/
- private fun determineMediaType() {
- collectionState
- .map { Pair(it.item, it.items) }
- .distinctUntilChanged()
- .catch { ex -> Timber.e(ex, "Error in determineMediaType") }
- .collectLatestIn(viewModelScope) { (item, items) ->
- var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN
- mediaType =
- if (mediaType == MediaType.UNKNOWN) {
- // Otherwise, if a most of the list is one type, we can assume that type
- val pager = (items.successValue as? ApiRequestPager<*>)
- if (pager != null && pager.size <= 50) {
- val types =
- (0..<50.coerceAtMost(pager.size)).groupBy { index ->
- val pagerItem = pager.getBlocking(index)
- when (pagerItem?.type) {
- BaseItemKind.AUDIO -> MediaType.AUDIO
+ private suspend fun determineMediaType() {
+ // Use the type the server says
+ var mediaType =
+ state.value.playlist
+ ?.data
+ ?.mediaType ?: MediaType.UNKNOWN
+ mediaType =
+ if (mediaType == MediaType.UNKNOWN) {
+ // Otherwise, if a most of the list is one type, we can assume that type
+ val pager = (state.value.items as? ApiRequestPager<*>)
+ if (pager != null && pager.size <= 50) {
+ val types =
+ (0..<50.coerceAtMost(pager.size)).groupBy { index ->
+ val pagerItem = pager.getBlocking(index)
+ when (pagerItem?.type) {
+ BaseItemKind.AUDIO -> MediaType.AUDIO
- BaseItemKind.VIDEO,
- BaseItemKind.EPISODE,
- BaseItemKind.MOVIE,
- BaseItemKind.BOX_SET,
- -> MediaType.VIDEO
+ BaseItemKind.VIDEO,
+ BaseItemKind.EPISODE,
+ BaseItemKind.MOVIE,
+ BaseItemKind.BOX_SET,
+ -> MediaType.VIDEO
- else -> MediaType.UNKNOWN
- }
- }
- if (types.keys.size == 1) {
- types.keys.first()
- } else {
- MediaType.UNKNOWN
+ else -> MediaType.UNKNOWN
}
- } else {
- MediaType.UNKNOWN
}
+ if (types.keys.size == 1) {
+ types.keys.first()
} else {
- mediaType
+ MediaType.UNKNOWN
}
- Timber.d("mediaType=%s", mediaType)
- _state.update {
- it.copy(mediaType = mediaType)
+ } else {
+ MediaType.UNKNOWN
}
+ } else {
+ mediaType
}
+ Timber.d("mediaType=%s", mediaType)
+ state.update {
+ it.copy(mediaType = mediaType)
+ }
+ }
+
+ suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
+ filterOptionCache.getFilterOptionValues(
+ itemId,
+ filterOption,
+ )
+
+ fun updateBackdrop(item: BaseItem) {
+ viewModelScope.launchIO {
+ backdropService.submit(item)
+ }
+ }
+
+ fun setWatched(
+ itemId: UUID,
+ played: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setWatched(itemId, played)
+ }
+
+ fun setFavorite(
+ itemId: UUID,
+ favorite: Boolean,
+ ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
+ favoriteWatchManager.setFavorite(itemId, favorite)
+ }
+
+ fun sendMediaReport(itemId: UUID) {
+ viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) }
}
fun removeFromPlaylist(
@@ -243,12 +392,11 @@ class PlaylistViewModel
) {
viewModelScope.launchIO {
try {
- api.playlistsApi.removeItemFromPlaylist(
- this@PlaylistViewModel.itemId.toServerString(),
- listOf(itemId.toServerString()),
+ playlistCreator.removeFromServerPlaylist(
+ playlistId = this@PlaylistViewModel.itemId,
+ itemId = itemId,
)
- (collectionState.value.items.successValue as? ApiRequestPager<*>)
- ?.refreshPagesAfter(index)
+ (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
@@ -275,15 +423,31 @@ class PlaylistViewModel
itemId = itemId.toServerString(),
newIndex = newIndex,
)
- (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter(
- index - 1,
- )
+ (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1)
}
}
}
+@Immutable
+data class FilterAndSort(
+ val filter: GetItemsFilter,
+ val sortAndDirection: SortAndDirection,
+)
+
data class PlaylistDetailsState(
+ val playlist: BaseItem? = null,
val mediaType: MediaType = MediaType.UNKNOWN,
+ val items: List = emptyList(),
+ val filterAndSort: FilterAndSort =
+ FilterAndSort(
+ filter = GetItemsFilter(),
+ sortAndDirection =
+ SortAndDirection(
+ ItemSortBy.DEFAULT,
+ SortOrder.ASCENDING,
+ ),
+ ),
+ val loading: LoadingState = LoadingState.Pending,
val canEdit: Boolean = false,
)
@@ -292,33 +456,14 @@ fun PlaylistDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
- collectionViewModel: CollectionFolderViewModel =
- hiltViewModel(
- creationCallback = {
- it.create(
- itemId = destination.itemId.toServerString(),
- initialSortAndDirection =
- SortAndDirection(
- ItemSortBy.DEFAULT,
- SortOrder.ASCENDING,
- ),
- recursive = false,
- collectionFilter = CollectionFolderFilter(),
- useSeriesForPrimary = false,
- defaultViewOptions = playlistDefaultViewOptions,
- )
- },
- ),
viewModel: PlaylistViewModel =
hiltViewModel(
- creationCallback = { it.create(destination.itemId, collectionViewModel.state) },
+ creationCallback = { it.create(destination.itemId) },
),
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
- val state by collectionViewModel.state.collectAsState()
- val playlistState by viewModel.state.collectAsState()
+ val state by viewModel.state.collectAsState()
val musicState by viewModel.musicState.collectAsState()
- val focusManager = LocalFocusManager.current
var showContextMenu by remember { mutableStateOf(null) }
var showConfirmTypeDialog by remember { mutableStateOf?>(null) }
@@ -331,15 +476,15 @@ fun PlaylistDetails(
shuffle: Boolean,
mediaTypeOverride: MediaType? = null,
) {
- when (mediaTypeOverride ?: playlistState.mediaType) {
+ when (mediaTypeOverride ?: state.mediaType) {
MediaType.VIDEO -> {
- collectionViewModel.navigateTo(
+ viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = shuffle,
- filter = state.filter,
- sortAndDirection = state.sortAndDirection,
+ filter = state.filterAndSort.filter,
+ sortAndDirection = state.filterAndSort.sortAndDirection,
),
)
}
@@ -353,152 +498,93 @@ fun PlaylistDetails(
}
}
}
-
- fun showContextMenu(
- index: Int,
- item: BaseItem,
- fromLongClick: Boolean,
- ) {
- showContextMenu =
- if (item.type == BaseItemKind.AUDIO) {
- val musicContextActions =
- MusicContextActions(
- navigateTo = { collectionViewModel.navigateTo(it) },
- onClickPlay = { index, item ->
- play(
- index,
- item,
- false,
- MediaType.AUDIO,
- )
- },
- onClickPlayNext = { index, item -> viewModel.playNext(item) },
- onClickAddToQueue = { item ->
- viewModel.addToQueue(
- item,
- Int.MAX_VALUE,
- )
- },
- onClickFavorite = { id, favorite ->
- collectionViewModel.setFavorite(index, id, favorite)
- },
- onClickAddPlaylist = { itemId ->
- addToPlaylistViewModel.loadPlaylists()
- showPlaylistDialog.makePresent(itemId)
- },
- onClickRemoveFromQueue = { _, _ -> },
- onDeleteItem = { collectionViewModel.deleteItem(index, item) },
- onRemoveFromPlaylist = viewModel::removeFromPlaylist,
- )
- ContextMenu.ForMusic(
- fromLongClick = fromLongClick,
- item = item,
- index = index,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- canRemoveFromQueue = false,
- actions = musicContextActions,
- showRemoveFromPlaylist = playlistState.canEdit,
- )
- } else {
- val contextActions =
- ContextMenuActions(
- navigateTo = { collectionViewModel.navigateTo(it) },
- onClickWatch = { id, watched ->
- collectionViewModel.setWatched(
- index,
- id,
- watched,
- )
- },
- onClickFavorite = { id, favorite ->
- collectionViewModel.setFavorite(index, id, favorite)
- },
- onClickAddPlaylist = { itemId ->
- addToPlaylistViewModel.loadPlaylists()
- showPlaylistDialog.makePresent(itemId)
- },
- onSendMediaInfo = collectionViewModel::sendReportFor,
- onDeleteItem = { collectionViewModel.deleteItem(index, item) },
- onClickAddToQueue = { viewModel.addToQueue(it, 0) },
- onShowOverview = {},
- onChooseVersion = { _, _ -> },
- onChooseTracks = {},
- onClearChosenStreams = {},
- onClickRemoveFromNextUp = {},
- onRemoveFromPlaylist = viewModel::removeFromPlaylist,
- )
- ContextMenu.ForBaseItem(
- fromLongClick = fromLongClick,
- item = item,
- index = index,
- chosenStreams = null,
- showGoTo = true,
- showStreamChoices = false,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- canRemoveContinueWatching = false,
- canRemoveNextUp = false,
- actions = contextActions,
- showRemoveFromPlaylist = playlistState.canEdit,
- )
- }
- }
-
- val actions =
+ val musicContextActions =
remember {
- GridClickActions(
- onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) },
- onLongClickItem = { index, item -> showContextMenu(index, item, true) },
- onClickPlayAll = { shuffle ->
- state.item.successValue?.let { playlist -> play(0, playlist, shuffle) }
+ MusicContextActions(
+ navigateTo = { viewModel.navigationManager.navigateTo(it) },
+ onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
+ onClickPlayNext = { index, item -> viewModel.playNext(item) },
+ onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
+ onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists()
+ showPlaylistDialog.makePresent(itemId)
},
- onClickPlayRemoteButton = { index, item -> play(index, item, false) },
+ onClickRemoveFromQueue = { _, _ -> },
+ onDeleteItem = viewModel::deleteItem,
+ onRemoveFromPlaylist = viewModel::removeFromPlaylist,
)
}
- CollectionFolderViewContent(
- preferences = preferences,
- state = state,
- savedPosition = viewModel.position,
- itemId = destination.itemId.toServerString(),
- initialFilter = CollectionFolderFilter(filter = state.filter),
- recursive = false,
- actions = actions,
- sortOptions = PlaylistSortOptions,
- playEnabled = true,
- defaultViewOptions = playlistDefaultViewOptions,
- viewActions = collectionViewModel,
- provider = collectionViewModel,
- positionCallback = { _, position -> viewModel.position = position },
- modifier = modifier,
- listItemContent = { (item, index, onClick, onLongClick, _, mod) ->
- PlaylistItem(
- item = item,
- index = index,
- onClick = onClick,
- onLongClick = onLongClick,
- canMove = playlistState.canEdit,
- moveUpAllowed = index > 0,
- moveDownAllowed =
- state.items.successValue
- ?.lastIndex
- ?.gt(index) ?: false,
- onClickMove = { direction ->
- item?.let { viewModel.onMoveItem(index, item.id, direction) }
- when (direction) {
- MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up)
- MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down)
- }
+ val contextActions =
+ remember {
+ ContextMenuActions(
+ navigateTo = { viewModel.navigationManager.navigateTo(it) },
+ onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
+ onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists()
+ showPlaylistDialog.makePresent(itemId)
},
- onClickMore = { item?.let { showContextMenu(index, item, false) } },
- modifier = mod,
-// interactionSource = TODO(),
- isPlaying =
- equalsNotNull(
- musicState.currentItemId,
- item?.id,
- ),
- isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds },
+ onSendMediaInfo = viewModel::sendMediaReport,
+ onDeleteItem = viewModel::deleteItem,
+ onClickAddToQueue = { viewModel.addToQueue(it, 0) },
+ onShowOverview = {},
+ onChooseVersion = { _, _ -> },
+ onChooseTracks = {},
+ onClearChosenStreams = {},
+ onClickRemoveFromNextUp = {},
+ onRemoveFromPlaylist = viewModel::removeFromPlaylist,
)
+ }
+
+ PlaylistDetailsContent(
+ loadingState = state.loading,
+ playlist = state.playlist,
+ items = state.items,
+ musicState = musicState,
+ onChangeBackdrop = viewModel::updateBackdrop,
+ onClickIndex = { index, item ->
+ play(index, item, false)
},
+ onClickPlay = { shuffle ->
+ state.playlist?.let {
+ play(0, it, shuffle)
+ }
+ },
+ onShowContextMenu = { index, item, fromLongClick ->
+ showContextMenu =
+ if (item.type == BaseItemKind.AUDIO) {
+ ContextMenu.ForMusic(
+ fromLongClick = fromLongClick,
+ item = item,
+ index = index,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ canRemoveFromQueue = false,
+ actions = musicContextActions,
+ showRemoveFromPlaylist = state.canEdit,
+ )
+ } else {
+ ContextMenu.ForBaseItem(
+ fromLongClick = fromLongClick,
+ item = item,
+ index = index,
+ chosenStreams = null,
+ showGoTo = true,
+ showStreamChoices = false,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ canRemoveContinueWatching = false,
+ canRemoveNextUp = false,
+ actions = contextActions,
+ showRemoveFromPlaylist = state.canEdit,
+ )
+ }
+ },
+ filterAndSort = state.filterAndSort,
+ onFilterAndSortChange = viewModel::loadItems,
+ getPossibleFilterValues = viewModel::getFilterOptionValues,
+ canEdit = state.canEdit,
+ onMoveItem = viewModel::onMoveItem,
+ modifier = modifier,
)
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
@@ -535,7 +621,294 @@ fun PlaylistDetails(
}
@Composable
-fun CollectionFolderViewScope.PlaylistItem(
+fun PlaylistDetailsContent(
+ playlist: BaseItem?,
+ items: List,
+ musicState: MusicServiceState,
+ onClickIndex: (Int, BaseItem) -> Unit,
+ onShowContextMenu: (Int, BaseItem, Boolean) -> Unit,
+ onClickPlay: (shuffle: Boolean) -> Unit,
+ onChangeBackdrop: (BaseItem) -> Unit,
+ onMoveItem: (Int, UUID, MoveDirection) -> Unit,
+ filterAndSort: FilterAndSort,
+ onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
+ getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
+ loadingState: LoadingState,
+ canEdit: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ var savedIndex by rememberSaveable { mutableIntStateOf(0) }
+ var focusedIndex by remember { mutableIntStateOf(savedIndex) }
+ val focusedItem = items.getOrNull(focusedIndex)
+ LaunchedEffect(focusedItem) {
+ focusedItem?.let(onChangeBackdrop)
+ }
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(loadingState) {
+ if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
+ focusRequester.tryRequestFocus()
+ }
+ }
+
+ val playButtonFocusRequester = remember { FocusRequester() }
+
+ Box(
+ modifier = modifier,
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ ) {
+ val title =
+ if (loadingState is LoadingState.Success) {
+ playlist?.name ?: stringResource(R.string.playlist)
+ } else {
+ ""
+ }
+ GridTitle(title)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ ) {
+ PlaylistDetailsHeader(
+ focusedItem = focusedItem,
+ onClickPlay = onClickPlay,
+ playButtonFocusRequester = playButtonFocusRequester,
+ focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
+ filterAndSort = filterAndSort,
+ onFilterAndSortChange = onFilterAndSortChange,
+ getPossibleFilterValues = getPossibleFilterValues,
+ filterOptions = DefaultPlaylistItemsOptions,
+ modifier =
+ Modifier
+ .padding(top = 80.dp)
+ .fillMaxWidth(.25f),
+ )
+ val filterCount =
+ remember(filterAndSort) {
+ filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions)
+ }
+ PlaylistItems(
+ loadingState = loadingState,
+ items = items,
+ musicState = musicState,
+ playButtonFocusRequester = playButtonFocusRequester,
+ onFocusItem = { index, item ->
+ focusedIndex = index
+ },
+ onClickItem = { index, item ->
+ savedIndex = index
+ item?.let { onClickIndex.invoke(index, item) }
+ },
+ onShowContextMenu = { index, item, fromLongClick ->
+ savedIndex = index
+ item?.let { onShowContextMenu.invoke(index, item, fromLongClick) }
+ },
+ canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0,
+ onMoveItem = onMoveItem,
+ modifier =
+ Modifier
+ .padding(start = 16.dp)
+ .weight(1f)
+ .focusRequester(focusRequester)
+ .focusProperties {
+ onExit = {
+ playButtonFocusRequester.tryRequestFocus()
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun PlaylistDetailsHeader(
+ focusedItem: BaseItem?,
+ filterOptions: List>,
+ onClickPlay: (shuffle: Boolean) -> Unit,
+ playButtonFocusRequester: FocusRequester,
+ focusRequester: FocusRequester,
+ filterAndSort: FilterAndSort,
+ onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
+ getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier,
+ ) {
+ ExpandablePlayButton(
+ title = R.string.play,
+ resume = Duration.ZERO,
+ icon = Icons.Default.PlayArrow,
+ onClick = { onClickPlay.invoke(false) },
+ modifier = Modifier.focusRequester(playButtonFocusRequester),
+ )
+ ExpandableFaButton(
+ title = R.string.shuffle,
+ iconStringRes = R.string.fa_shuffle,
+ onClick = { onClickPlay.invoke(true) },
+ )
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier,
+ ) {
+ FilterByButton(
+ filterOptions = filterOptions,
+ current = filterAndSort.filter,
+ onFilterChange = {
+ onFilterAndSortChange.invoke(
+ it,
+ filterAndSort.sortAndDirection,
+ )
+ },
+ getPossibleValues = getPossibleFilterValues,
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ SortByButton(
+ sortOptions = BoxSetSortOptions,
+ current = filterAndSort.sortAndDirection,
+ onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) },
+ )
+ }
+ Text(
+ text = focusedItem?.title ?: "",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Text(
+ text = focusedItem?.subtitle ?: "",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) {
+ Text(
+ text = formatDateTime(focusedItem.data.premiereDate!!),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ OverviewText(
+ overview = focusedItem?.data?.overview ?: "",
+ maxLines = 10,
+ onClick = {},
+ enabled = false,
+ )
+ }
+}
+
+@Composable
+fun PlaylistItems(
+ loadingState: LoadingState,
+ items: List,
+ musicState: MusicServiceState,
+ playButtonFocusRequester: FocusRequester,
+ canMove: Boolean,
+ onMoveItem: (Int, UUID, MoveDirection) -> Unit,
+ onFocusItem: (Int, BaseItem?) -> Unit,
+ onClickItem: (Int, BaseItem?) -> Unit,
+ onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+ when (loadingState) {
+ is LoadingState.Error -> {
+ ErrorMessage(loadingState, modifier)
+ }
+
+ LoadingState.Pending, LoadingState.Loading -> {
+ LoadingPage(modifier)
+ }
+
+ LoadingState.Success -> {
+ if (items.isNotEmpty()) {
+ LazyColumn(
+ contentPadding = PaddingValues(8.dp),
+ modifier =
+ modifier
+ .padding(bottom = 32.dp)
+ .fillMaxHeight()
+ .background(
+ MaterialTheme.colorScheme
+ .surfaceColorAtElevation(1.dp)
+ .copy(alpha = .75f),
+ shape = RoundedCornerShape(16.dp),
+ ).focusGroup()
+ .focusRestorer(),
+ ) {
+ itemsIndexed(items) { index, item ->
+ PlaylistItem(
+ item = item,
+ index = index,
+ onClick = {
+ onClickItem.invoke(index, item)
+ },
+ onLongClick = {
+ onShowContextMenu.invoke(index, item, true)
+ },
+ isPlaying =
+ equalsNotNull(
+ musicState.currentItemId,
+ item?.id,
+ ),
+ isQueued = item?.id in musicState.queuedIds,
+ canMove = canMove,
+ moveUpAllowed = index > 0,
+ moveDownAllowed = index < items.lastIndex,
+ onClickMove = { direction ->
+ item?.let { onMoveItem.invoke(index, item.id, direction) }
+ when (direction) {
+ MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up)
+ MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down)
+ }
+ },
+ onClickMore = { onShowContextMenu.invoke(index, item, false) },
+ modifier =
+ Modifier
+ .animateItem()
+ .ifElse(
+ item?.type != BaseItemKind.AUDIO,
+ Modifier.height(80.dp),
+ ).onFocusChanged {
+ if (it.hasFocus) {
+ onFocusItem(index, item)
+ }
+ },
+ )
+ }
+ }
+ } else {
+ LaunchedEffect(Unit) {
+ playButtonFocusRequester.tryRequestFocus()
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(R.string.no_results),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PlaylistItem(
item: BaseItem?,
index: Int,
onClick: () -> Unit,
@@ -550,18 +923,19 @@ fun CollectionFolderViewScope.PlaylistItem(
isPlaying: Boolean = false,
isQueued: Boolean = false,
) {
- val dense = viewOptions.type == ViewOptionsType.DENSE_LIST
val focused by interactionSource.collectIsFocusedAsState()
+ val imageWidth = 160.dp
+ val density = LocalDensity.current
+ val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } }
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier
- .fillMaxWidth(),
-// .heightIn(min = 40.dp, max = 88.dp),
+ .fillMaxWidth()
+ .heightIn(min = 40.dp, max = 88.dp),
) {
- ListItemWrapper(
- dense = dense,
+ ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
@@ -580,21 +954,19 @@ fun CollectionFolderViewScope.PlaylistItem(
},
trailingContent = {
val duration =
- remember(item?.data?.runTimeTicks) {
- when (item?.type) {
- BaseItemKind.AUDIO -> {
- item.data.runTimeTicks
- ?.ticks
- ?.roundSeconds
- }
+ when (item?.type) {
+ BaseItemKind.AUDIO -> {
+ item.data.runTimeTicks
+ ?.ticks
+ ?.roundSeconds
+ }
- else -> {
- item
- ?.data
- ?.runTimeTicks
- ?.ticks
- ?.roundMinutes
- }
+ else -> {
+ item
+ ?.data
+ ?.runTimeTicks
+ ?.ticks
+ ?.roundMinutes
}
}
duration?.let { duration ->
@@ -608,25 +980,15 @@ fun CollectionFolderViewScope.PlaylistItem(
val resources = LocalResources.current
val durationText =
remember(resources, duration) { resources.formatDuration(duration) }
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Column {
+ Column {
+ Text(
+ text = durationText,
+ )
+ if (item?.type != BaseItemKind.AUDIO) {
Text(
- text = durationText,
+ text = stringResource(R.string.ends_at, endTimeStr),
+ style = MaterialTheme.typography.bodySmall,
)
- if (item?.type != BaseItemKind.AUDIO && !dense) {
- val style =
- LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) }
- Text(
- text = stringResource(R.string.ends_at, endTimeStr),
- style = style,
- )
- }
- }
- if (item?.played == true) {
- WatchedIcon(padding = 0.dp)
}
}
}
@@ -634,29 +996,41 @@ fun CollectionFolderViewScope.PlaylistItem(
leadingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
- if (sortAndDirection.sort == ItemSortBy.DEFAULT) {
-// val indexText =
-// remember(index, collectionSize, sortAndDirection) {
-// when (sortAndDirection.direction) {
-// SortOrder.ASCENDING -> "${index + 1}."
-// SortOrder.DESCENDING -> "${collectionSize - index}."
-// }
-// }
- // TODO default descending doesn't work?
- val indexText = "${index + 1}."
- Text(
- text = indexText,
- )
- }
+ Text(
+ text = "${index + 1}.",
+ style = MaterialTheme.typography.labelLarge,
+ )
if (item?.type == BaseItemKind.AUDIO) {
MusicQueueMarker(
isPlaying = isPlaying,
isQueued = isQueued,
)
+ } else {
+ val imageType =
+ remember(item) {
+ if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) {
+ ImageType.THUMB
+ } else {
+ ImageType.PRIMARY
+ }
+ }
+ ItemCardImage(
+ item = item,
+ name = item?.name,
+ imageType = imageType,
+ showOverlay = true,
+ favorite = item?.data?.userData?.isFavorite ?: false,
+ watched = item?.data?.userData?.played ?: false,
+ unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
+ watchedPercent = 0.0,
+ numberOfVersions = item?.data?.mediaSourceCount ?: 0,
+ modifier = Modifier.width(imageWidth),
+ useFallbackText = false,
+ fillWidth = imageWidthPx,
+ )
}
- if (item?.favorite == true) FavoriteIndicator()
}
},
modifier = Modifier.weight(1f),
@@ -746,10 +1120,3 @@ fun ConfirmMediaTypeDialog(
}
}
}
-
-private val playlistDefaultViewOptions =
- ViewOptions(
- spacing = 0,
- showDetails = true,
- type = ViewOptionsType.LIST,
- )
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
index d0017d84..c5686e8f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
@@ -42,8 +42,9 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
+import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
-import com.github.damontecres.wholphin.data.model.canRequestMovies
+import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@@ -83,12 +84,14 @@ fun DiscoverMovieDetails(
}
val state by viewModel.state.collectAsState()
val userConfig by viewModel.userConfig.collectAsState(null)
- val requestEnabled by viewModel.requestEnabled.collectAsState(false)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf(null) }
var showRequestDialog by remember { mutableStateOf(false) }
+ val requestStr = stringResource(R.string.request)
+ val request4kStr = stringResource(R.string.request_4k)
+
when (val st = state.movie) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
@@ -150,7 +153,6 @@ fun DiscoverMovieDetails(
RequestMovieDialog(
loading = state.profileLoading,
data = state.requestData,
- requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = {
@@ -251,7 +253,7 @@ fun DiscoverMovieDetailsContent(
}
}
},
- canRequest = userConfig.canRequestMovies,
+ canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
index 407c6935..658e4ffe 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
@@ -75,8 +75,6 @@ class DiscoverMovieViewModel
val state: StateFlow = _state
val userConfig = seerrServerRepository.current.map { it?.config }
- val requestEnabled =
- seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
@@ -196,7 +194,7 @@ class DiscoverMovieViewModel
return@launchIO
}
try {
- val postRequest =
+ seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = request.is4k,
mediaId = request.movieId,
@@ -210,9 +208,8 @@ class DiscoverMovieViewModel
profileId = request.profileId,
rootFolder = request.folder,
tags = emptyList(),
- )
- Timber.v("postRequest=%s", postRequest)
- seerrService.api.requestApi.requestPost(postRequest)
+ ),
+ )
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
index 860af84e..ef96bb7e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
@@ -37,8 +37,9 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
+import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
-import com.github.damontecres.wholphin.data.model.canRequestTv
+import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@@ -87,7 +88,6 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current
val resources = LocalResources.current
val state by viewModel.state.collectAsState()
- val requestEnabled by viewModel.requestEnabled.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
var overviewDialog by remember { mutableStateOf(null) }
@@ -112,7 +112,6 @@ fun DiscoverSeriesDetails(
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
- availability = state.availability,
userConfig = userConfig,
rating = state.rating,
canCancel = state.canCancelRequest,
@@ -189,7 +188,6 @@ fun DiscoverSeriesDetails(
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
seasons4k = state.seasons4k,
- requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = {
showRequestSeasonDialog = false
@@ -214,7 +212,6 @@ fun DiscoverSeriesDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
series: TvDetails,
- availability: SeerrAvailability,
rating: SourceRatings?,
canCancel: Boolean,
seasons: List,
@@ -278,7 +275,9 @@ fun DiscoverSeriesDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandableDiscoverButtons(
- availability = availability,
+ availability =
+ SeerrAvailability.from(series.mediaInfo?.status)
+ ?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
@@ -292,7 +291,7 @@ fun DiscoverSeriesDetailsContent(
}
}
},
- canRequest = userConfig.canRequestTv,
+ canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
index c20ab9c9..b4d6f37f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
@@ -75,10 +75,6 @@ class DiscoverSeriesViewModel
val state: StateFlow = _state
val userConfig = seerrServerRepository.current.map { it?.config }
- val requestEnabled =
- seerrServerRepository.current
- .map { it?.requestTvEnabled ?: false }
- .stateIn(viewModelScope, SharingStarted.Eagerly, false)
val request4kEnabled =
seerrServerRepository.current
.map { it?.request4kTvEnabled ?: false }
@@ -112,7 +108,7 @@ class DiscoverSeriesViewModel
backdropService.submit(discoveredItem)
updateSeasonStatus(tv)
- updateAvailability()
+ updateCanCancel()
viewModelScope.launchIO {
val rating =
@@ -194,7 +190,6 @@ class DiscoverSeriesViewModel
tv: TvDetails,
is4k: Boolean,
) {
- Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
val currentUserId = seerrServerRepository.currentUserId.first()
val seasonStatus = mutableMapOf()
val seasonAvailability = mutableMapOf()
@@ -235,10 +230,7 @@ class DiscoverSeriesViewModel
seasonStatus[season.seasonNumber] = new
}
editable[season.seasonNumber] =
- currentUserId == req.requestedBy?.id && (
- req.status == RequestStatus.PENDING.status ||
- req.status == RequestStatus.DECLINED.status
- )
+ currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
}
}
}
@@ -307,7 +299,7 @@ class DiscoverSeriesViewModel
}
}
- private suspend fun updateAvailability() {
+ private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull()
val canCancel =
canUserCancelRequest(
@@ -316,32 +308,7 @@ class DiscoverSeriesViewModel
?.mediaInfo
?.requests,
)
- val status =
- state.value.tvSeries.successValue?.let { tv ->
- val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
- if (overallStatus == SeerrAvailability.UNKNOWN) {
- val userRequests =
- tv.mediaInfo
- ?.requests
- ?.filter { it.requestedBy?.id == user?.id }
- .orEmpty()
- if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
- SeerrAvailability.PENDING
- } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
- SeerrAvailability.PROCESSING
- } else {
- SeerrAvailability.UNKNOWN
- }
- } else {
- overallStatus
- }
- }
- _state.update {
- it.copy(
- canCancelRequest = canCancel,
- availability = status ?: SeerrAvailability.UNKNOWN,
- )
- }
+ _state.update { it.copy(canCancelRequest = canCancel) }
}
fun requestOnClick() {
@@ -424,7 +391,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
- updateAvailability()
+ updateCanCancel()
}
}
}
@@ -445,7 +412,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
- updateAvailability()
+ updateCanCancel()
}
}
}
@@ -464,5 +431,4 @@ data class DiscoverSeriesState(
val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(),
- val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
index ef6e54b7..7a009f09 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
@@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.util.LoadingState
fun RequestMovieDialog(
loading: LoadingState,
data: SeerrRequestData,
- requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@@ -55,11 +54,10 @@ fun RequestMovieDialog(
RequestMovie(
loading = loading,
data = data,
- requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
- modifier = Modifier.padding(24.dp),
+ modifier = Modifier.padding(16.dp),
)
}
}
@@ -68,7 +66,6 @@ fun RequestMovieDialog(
fun RequestMovie(
loading: LoadingState,
data: SeerrRequestData,
- requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@@ -93,8 +90,7 @@ fun RequestMovie(
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
) {
- // Both enabled, show choice
- if (requestEnabled && request4kEnabled) {
+ if (request4kEnabled) {
val items =
remember {
listOf(
@@ -122,13 +118,12 @@ fun RequestMovie(
)
} else {
LaunchedEffect(Unit) {
- onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
+ onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
}
}
} else {
RequestMovieWithOptions(
data = data,
- requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
@@ -142,13 +137,12 @@ fun RequestMovie(
@Composable
private fun RequestMovieWithOptions(
data: SeerrRequestData,
- requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier,
) {
- var is4k by remember { mutableStateOf(!requestEnabled) }
+ var is4k by remember { mutableStateOf(request4kEnabled) }
var profile by remember(is4k) {
mutableStateOf(
if (is4k) {
@@ -191,7 +185,7 @@ private fun RequestMovieWithOptions(
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
- if (requestEnabled && request4kEnabled) {
+ if (request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@@ -383,7 +377,7 @@ fun MovieRequestPreview() {
),
profiles4k =
listOf(
- SeerrProfile(1, "4K", true),
+ SeerrProfile(1, "HD", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
@@ -391,8 +385,7 @@ fun MovieRequestPreview() {
SeerrRootFolder(1, "/movies", "400GB", true),
),
),
- requestEnabled = true,
- request4kEnabled = true,
+ request4kEnabled = false,
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
onSubmit = {},
modifier =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
index 91a10279..bc11825f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
@@ -65,12 +65,11 @@ fun RequestSeasons(
seasons: List,
seasons4k: List,
data: SeerrRequestData,
- requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) {
- var is4k by remember { mutableStateOf(!requestEnabled) }
+ var is4k by remember { mutableStateOf(request4kEnabled) }
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
val allSeasonNumbers =
@@ -162,7 +161,7 @@ fun RequestSeasons(
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
- if (requestEnabled && request4kEnabled) {
+ if (request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@@ -392,7 +391,6 @@ fun RequestSeasonsDialog(
data: SeerrRequestData,
seasons: List,
seasons4k: List,
- requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit,
@@ -420,7 +418,6 @@ fun RequestSeasonsDialog(
data = data,
seasons = seasons,
seasons4k = seasons4k,
- requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier =
@@ -478,7 +475,6 @@ fun RequestSeasonsPreview() {
SeerrRootFolder(1, "/tv", "400GB", true),
),
),
- requestEnabled = true,
request4kEnabled = true,
onSubmit = { },
modifier = Modifier.width(400.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
index 97aaaf86..a45fbda0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
@@ -145,10 +145,11 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -162,6 +163,7 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -169,7 +171,10 @@ class EpisodeViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ val chosen =
+ result?.let {
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ }
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
index bcba145c..470f33c2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
@@ -149,11 +149,7 @@ class LiveTvViewModel
number = it.channelNumber,
name = it.channelName ?: it.name,
imageUrl =
- imageUrlService.getItemImageUrl(
- itemId = it.id,
- imageType = ImageType.PRIMARY,
- tag = it.imageTags?.get(ImageType.PRIMARY),
- ),
+ imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
favorite = it.userData?.isFavorite == true,
)
}
@@ -303,7 +299,6 @@ class LiveTvViewModel
imageUrlService.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
- tag = dto.imageTags?.get(ImageType.PRIMARY),
),
)
if (index == 0) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index 9a8dd033..db6a6f6a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
@@ -223,10 +223,11 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -240,6 +241,7 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -247,7 +249,10 @@ class MovieViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ val chosen =
+ result?.let {
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ }
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 3bbc3a6b..32f1d484 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -561,10 +561,11 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -578,6 +579,7 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -585,7 +587,10 @@ class SeriesViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
+ val chosen =
+ result?.let {
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ }
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index d02ba907..847e2e69 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
-import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.LatestNextUpService
@@ -63,7 +62,6 @@ class HomeViewModel
val serverReportService: ServerReportService,
private val navDrawerService: NavDrawerService,
private val homeSettingsService: HomeSettingsService,
- private val homeDataService: HomeDataService,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
@@ -188,7 +186,7 @@ class HomeViewModel
semaphore.withPermit {
Timber.v("Fetching row: %s", row)
try {
- homeDataService.fetchDataForRow(
+ homeSettingsService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
index ae958a93..ad433c23 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
@@ -33,9 +33,6 @@ sealed interface HomeSettingsDestination : NavKey {
@Serializable
data object GlobalSettings : HomeSettingsDestination
- @Serializable
- data object SourceSettings : HomeSettingsDestination
-
@Serializable
data object Presets : HomeSettingsDestination
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
index 74c546b5..894980c4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
@@ -31,6 +31,10 @@ fun HomeSettingsGlobal(
preferences: AppPreferences,
onPreferenceChange: (AppPreferences) -> Unit,
onClickResize: (Int) -> Unit,
+ onClickSave: () -> Unit,
+ onClickLoad: () -> Unit,
+ onClickLoadWeb: () -> Unit,
+ onClickReset: () -> Unit,
onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -142,6 +146,62 @@ fun HomeSettingsGlobal(
modifier = Modifier,
)
}
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.save_to_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_up),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickSave,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_down),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoad,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_web_client),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoadWeb,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.reset),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_arrows_rotate),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickReset,
+ modifier = Modifier,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
index 0bd6b7db..5a2d6c78 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
@@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.main.settings
-import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -65,14 +64,11 @@ fun HomeSettingsPage(
var showConfirmDialog by remember { mutableStateOf(null) }
var searchForDialog by remember { mutableStateOf(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
- var showSaveDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
- val serverPluginActive by viewModel.serverPluginActive.collectAsState()
var position by rememberPosition(0, 0)
// TODO discover rows
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
- val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
// Adds a row, waits until its done loading, then scrolls to the new row
fun addRow(
@@ -90,10 +86,6 @@ fun HomeSettingsPage(
}
}
- BackHandler(settingsHaveChanged) {
- showSaveDialog = true
- }
-
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@@ -145,7 +137,6 @@ fun HomeSettingsPage(
position = RowColumn(index, 0)
}
},
- onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
modifier = destModifier,
)
}
@@ -291,16 +282,6 @@ fun HomeSettingsPage(
}
},
onClickResize = { viewModel.resizeCards(it) },
- onClickViewNextUp = {
- showRemovedNextUpDialog = true
- },
- modifier = destModifier,
- )
- }
-
- HomeSettingsDestination.SourceSettings -> {
- HomeSettingsSourcePage(
- source = state.source,
onClickSave = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_server_settings) {
@@ -313,13 +294,6 @@ fun HomeSettingsPage(
viewModel.loadFromRemote()
}
},
- serverPluginActive = serverPluginActive,
- onClickLoadPlugin = {
- showConfirmDialog =
- ShowConfirm(R.string.overwrite_local_settings) {
- viewModel.loadFromRemotePlugin()
- }
- },
onClickLoadWeb = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
@@ -332,6 +306,9 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
+ onClickViewNextUp = {
+ showRemovedNextUpDialog = true
+ },
modifier = destModifier,
)
}
@@ -398,29 +375,6 @@ fun HomeSettingsPage(
)
}
}
- if (showSaveDialog) {
- SaveHomeSettingsDialog(
- onDismissRequest = { showSaveDialog = false },
- onSaveLocal = {
- scope.launch {
- viewModel.saveToLocal().join()
- viewModel.navigationManager.goBack()
- }
- },
- onSaveServer = {
- scope.launch {
- viewModel.saveToRemote().join()
- viewModel.navigationManager.goBack()
- }
- },
- onDiscard = {
- scope.launch {
- viewModel.discardChanges().join()
- viewModel.navigationManager.goBack()
- }
- },
- )
- }
}
data class ShowConfirm(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
index 72289463..dc527ed4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
@@ -49,11 +49,6 @@ enum class MoveDirection {
DOWN,
}
-private const val POS_ADD_ROW = 0
-private const val POS_SOURCE = POS_ADD_ROW + 1
-private const val POS_SETTINGS = POS_SOURCE + 1
-private const val POS_PRESETS = POS_SETTINGS + 1
-
@Composable
fun HomeSettingsRowList(
state: HomePageSettingsState,
@@ -63,14 +58,13 @@ fun HomeSettingsRowList(
onClickPresets: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit,
- onClickSourceSettings: () -> Unit,
modifier: Modifier,
) {
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
- val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
+ val itemsBeforeRows = 4
val focusRequesters =
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
@@ -86,7 +80,7 @@ fun HomeSettingsRowList(
modifier =
modifier
.fillMaxHeight()
- .focusRestorer(focusRequesters[POS_ADD_ROW]),
+ .focusRestorer(focusRequesters[0]),
) {
item {
HomeSettingsListItem(
@@ -99,30 +93,10 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = POS_ADD_ROW
+ position = 0
onClickAdd.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
- )
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = "Source",
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_download),
- fontFamily = FontAwesome,
- )
- },
- supportingContent = {
- Text(text = stringResource(state.source.stringResId))
- },
- onClick = {
- position = POS_SOURCE
- onClickSourceSettings.invoke()
- },
- modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
+ modifier = Modifier.focusRequester(focusRequesters[0]),
)
}
item {
@@ -136,10 +110,10 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = POS_SETTINGS
+ position = 1
onClickSettings.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
+ modifier = Modifier.focusRequester(focusRequesters[1]),
)
}
item {
@@ -158,10 +132,10 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = POS_PRESETS
+ position = 2
onClickPresets.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
+ modifier = Modifier.focusRequester(focusRequesters[2]),
)
}
item {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt
deleted file mode 100644
index 8139b651..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-package com.github.damontecres.wholphin.ui.main.settings
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRestorer
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.Text
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.services.HomePageSettingsSource
-import com.github.damontecres.wholphin.ui.FontAwesome
-import com.github.damontecres.wholphin.ui.tryRequestFocus
-
-@Composable
-fun HomeSettingsSourcePage(
- source: HomePageSettingsSource,
- onClickSave: () -> Unit,
- onClickLoad: () -> Unit,
- onClickLoadWeb: () -> Unit,
- onClickReset: () -> Unit,
- serverPluginActive: Boolean,
- onClickLoadPlugin: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val firstFocus: FocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
- Column(modifier = modifier) {
- Text(
- text = stringResource(R.string.settings),
- style = MaterialTheme.typography.titleLarge,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier =
- modifier
- .fillMaxHeight()
- .focusRestorer(firstFocus),
- ) {
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.load_from_server),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_cloud_arrow_down),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickLoad,
- modifier = Modifier,
- )
- }
- if (serverPluginActive) {
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.load_from_server_plugin),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_download),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickLoadPlugin,
- modifier = Modifier,
- )
- }
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.load_from_web_client),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_download),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickLoadWeb,
- modifier = Modifier,
- )
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.reset),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_arrows_rotate),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickReset,
- modifier = Modifier,
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
index 1082a23f..e3eafcbb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
@@ -24,18 +24,16 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
-import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
-import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.NavDrawerService
-import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
-import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
+import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
+import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.launchDefault
@@ -50,16 +48,13 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -81,55 +76,37 @@ class HomeSettingsViewModel
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val homeSettingsService: HomeSettingsService,
- private val homeDataService: HomeDataService,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
private val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
- private val serverPluginApi: ServerPluginApi,
val preferencesDataStore: DataStore,
- val navigationManager: NavigationManager,
+ @param:IoCoroutineScope private val ioScope: CoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow = _state
- val serverPluginActive get() = serverRepository.serverPluginInstalled
- val currentUser
- get() =
- serverRepository.currentUserFlow.filterNotNull().stateIn(
- viewModelScope,
- SharingStarted.Eagerly,
- serverRepository.currentUser!!,
- )
- val settingsChanged =
- state
- .map {
- HomePageSettings(
- rows = it.rows.map { it.config },
- version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
- ) != originalSettings
- }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
-
private var idCounter by Delegates.notNull()
val discoverEnabled = seerrServerRepository.active
- private var originalSettings: HomePageSettings? = null
+ private var originalLocalSettings: HomePageSettings? = null
+ private var originalRemoteSettings: HomePageSettings? = null
init {
+ addCloseable { saveToLocal() }
viewModelScope.launchIO {
- val user = serverRepository.currentUser ?: return@launchIO
val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
- originalSettings = currentSettings.asHomePageSettings()
+ originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
+ originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
Timber.v("currentSettings=%s", currentSettings)
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
- source = currentSettings.source,
libraries = libraries,
rows = currentSettings.rows,
)
@@ -157,7 +134,7 @@ class HomeSettingsViewModel
viewModelScope.async(WholphinDispatchers.IO) {
semaphore.withPermit {
try {
- homeDataService.fetchDataForRow(
+ homeSettingsService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
@@ -519,7 +496,7 @@ class HomeSettingsViewModel
}
}
- fun saveToRemote(): Job =
+ fun saveToRemote() {
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote")
@@ -529,10 +506,6 @@ class HomeSettingsViewModel
try {
Timber.d("saveToRemote")
homeSettingsService.saveToServer(user.id, settings)
- homeSettingsService.updateCurrent(
- HomePageSettingsSource.SERVER_PROFILE,
- settings,
- )
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@@ -540,6 +513,7 @@ class HomeSettingsViewModel
}
}
}
+ }
fun loadFromRemote() {
viewModelScope.launchIO {
@@ -556,10 +530,7 @@ class HomeSettingsViewModel
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(
- source = HomePageSettingsSource.SERVER_PROFILE,
- rows = newRows,
- )
+ it.copy(rows = newRows)
}
} else {
Timber.v("No remote settings")
@@ -589,10 +560,7 @@ class HomeSettingsViewModel
Timber.v("Got web settings")
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(
- source = HomePageSettingsSource.LOCAL,
- rows = result.rows,
- )
+ it.copy(rows = result.rows)
}
} else {
Timber.v("No web settings")
@@ -607,19 +575,34 @@ class HomeSettingsViewModel
}
}
- fun saveToLocal(): Job =
- viewModelScope.launchIO {
+ fun saveToLocal() {
+ // This uses injected ioScope so that it will still run when the page is closing
+ ioScope.launchIO {
serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try {
Timber.d("saveToLocal")
+ // Only save if there are changes based on original source
+ val shouldSave =
+ if (originalLocalSettings != null) {
+ originalLocalSettings != settings
+ } else if (originalRemoteSettings != null) {
+ originalRemoteSettings != settings
+ } else {
+ true
+ }
+ if (shouldSave) {
+ homeSettingsService.saveToLocal(user.id, settings)
+ homeSettingsService.updateCurrent(user.id, settings)
+ showSaveToast()
+ } else {
+ Timber.d("No changes")
+ }
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ Timber.w(ex, "Overwriting local settings")
homeSettingsService.saveToLocal(user.id, settings)
- homeSettingsService.updateCurrent(
- HomePageSettingsSource.LOCAL,
- settings,
- )
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@@ -627,11 +610,17 @@ class HomeSettingsViewModel
}
}
}
+ }
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
_state.update {
update.invoke(it)
}
+ serverRepository.currentUser?.id?.let { userId ->
+ homeSettingsService.currentSettings.update {
+ HomePageResolvedSettings(userId, state.value.rows)
+ }
+ }
}
fun resizeCards(relative: Int) {
@@ -660,32 +649,12 @@ class HomeSettingsViewModel
fun resetToDefault() =
viewModelScope.launchIO {
- val user = serverRepository.currentUser ?: return@launchIO
+ val userId = serverRepository.currentUser?.id ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) }
- val result =
- if (serverPluginActive.value) {
- try {
- val settings =
- homeSettingsService.fetchSettingsFor(
- user.id,
- HomePageSettingsSource.PLUGIN,
- )
- settings
- } catch (ex: Exception) {
- Timber.e(ex, "Plugin is active, but error fetching home settings")
- null
- }
- } else {
- null
- } ?: homeSettingsService.createDefault(user.id)
-
+ val result = homeSettingsService.createDefault(userId)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(
- source = result.source,
- rows = result.rows,
- rowData = emptyList(),
- )
+ it.copy(rows = result.rows, rowData = emptyList())
}
fetchRowData()
}
@@ -777,10 +746,6 @@ class HomeSettingsViewModel
is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv)
}
-
- is HomeRowConfig.CustomEndpoint -> {
- it.config
- }
}
it.copy(config = newConfig)
}
@@ -802,43 +767,6 @@ class HomeSettingsViewModel
}
}
- fun loadFromRemotePlugin() {
- viewModelScope.launchIO {
- Timber.d("Loading home settings from server plugin")
- try {
- // TODO should this remove local settings?
- // TODO how to set up priorities
- _state.update { it.copy(loading = LoadingState.Loading) }
- val result = serverPluginApi.fetchHomePageSettings()
- if (result != null) {
- Timber.v("Got remote settings")
- val newRows =
- result.rows.mapIndexed { index, config ->
- homeSettingsService.resolve(index, config)
- }
- idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
- _state.update {
- it.copy(
- source = HomePageSettingsSource.PLUGIN,
- rows = newRows,
- )
- }
- } else {
- Timber.v("No server plugin settings")
- showToast(context, "No server plugin settings found")
- }
- fetchRowData()
- } catch (ex: UnsupportedHomeSettingsVersionException) {
- // TODO
- Timber.w(ex)
- showToast(context, "Error: ${ex.localizedMessage}")
- } catch (ex: Exception) {
- Timber.e(ex)
- showToast(context, "Error: ${ex.localizedMessage}")
- }
- }
- }
-
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
@@ -933,13 +861,10 @@ class HomeSettingsViewModel
fetchRowData()
}
}
-
- fun discardChanges(): Job = Job().apply { complete() }
}
data class HomePageSettingsState(
val loading: LoadingState,
- val source: HomePageSettingsSource,
val rows: List,
val rowData: List,
val libraries: List,
@@ -948,7 +873,6 @@ data class HomePageSettingsState(
val EMPTY =
HomePageSettingsState(
LoadingState.Pending,
- HomePageSettingsSource.UNSET,
emptyList(),
emptyList(),
emptyList(),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt
deleted file mode 100644
index c7531b6d..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.github.damontecres.wholphin.ui.main.settings
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalResources
-import androidx.compose.ui.unit.dp
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.ui.components.DialogItem
-import com.github.damontecres.wholphin.ui.components.DialogParams
-import com.github.damontecres.wholphin.ui.components.DialogPopup
-
-@Composable
-fun SaveHomeSettingsDialog(
- onDismissRequest: () -> Unit,
- onSaveLocal: () -> Unit,
- onSaveServer: () -> Unit,
- onDiscard: () -> Unit,
-) {
- val resources = LocalResources.current
- val params =
- remember(resources) {
- DialogParams(
- fromLongClick = false,
- title = resources.getString(R.string.save),
- items =
- listOf(
- DialogItem(
- text = R.string.save_to_local,
- iconStringRes = R.string.fa_cloud_arrow_down,
- onClick = onSaveLocal,
- ),
- DialogItem(
- text = R.string.save_to_server,
- iconStringRes = R.string.fa_cloud_arrow_up,
- onClick = onSaveServer,
- ),
- DialogItem(
- text = "Discard",
- icon = Icons.Default.Delete,
- iconColor = Color.Red,
- onClick = onDiscard,
- ),
- ),
- )
- }
- DialogPopup(
- params = params,
- onDismissRequest = onDismissRequest,
- elevation = 3.dp,
- )
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index 110cb637..a65c97e8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
@@ -134,7 +134,7 @@ sealed class Destination(
@Serializable
data class Slideshow(
- val parentId: UUID?,
+ val parentId: UUID,
val index: Int,
val filter: CollectionFolderFilter,
val sortAndDirection: SortAndDirection,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
index c26cb891..17906fe2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
@@ -80,7 +80,6 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
-import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@@ -574,7 +573,6 @@ fun NavigationDrawerScope.ProfileIcon(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
- val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem(
modifier = modifier,
selected = false,
@@ -597,7 +595,7 @@ fun NavigationDrawerScope.ProfileIcon(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier.enableMarquee(focused),
+ modifier = Modifier,
text = user.name ?: user.id.toString(),
maxLines = 1,
)
@@ -641,7 +639,7 @@ fun NavigationDrawerScope.IconNavItem(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier.enableMarquee(focused),
+ modifier = Modifier,
text = text,
maxLines = 1,
)
@@ -735,7 +733,7 @@ fun NavigationDrawerScope.NavItem(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier.enableMarquee(focused),
+ modifier = Modifier,
text = library.name(context),
maxLines = 1,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
index b17036b8..871264c2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
@@ -25,7 +25,6 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@@ -159,11 +158,7 @@ class PlayExternalViewModel
is PlaylistItem.Media -> playlistItem.item
}
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
- val subtitleStc =
- streamChoiceService.getSeriesTrackChoices(
- item.data,
- SeriesTrackChoiceType.SUBTITLE,
- )
+ val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
if (mediaSource == null) {
Timber.w("Media source is null")
return@launchDefault
@@ -173,14 +168,13 @@ class PlayExternalViewModel
val subtitleIndex =
streamChoiceService
.chooseSubtitleStream(
- item = item.data,
source = mediaSource,
audioStream = null,
+ seriesId = item.data.seriesId,
itemPlayback = playbackConfig,
- stc = subtitleStc,
+ plc = plc,
prefs = prefs,
- )?.stream
- ?.index
+ )?.index
val externalSubtitles =
mediaSource.mediaStreams
?.filter { it.isExternal }
@@ -243,8 +237,6 @@ class PlayExternalViewModel
}
// VLC
- // A zero position alone does not override VLC's saved resume position.
- putExtra("from_start", positionMs == 0L)
if (subtitleUrls.isNotEmpty()) {
putExtra("subtitles_location", subtitleUrls.first().toString())
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
index 3165246f..491498cb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
@@ -42,10 +42,8 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
-import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
-import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
@@ -62,7 +60,6 @@ enum class PlaybackDialogType {
PLAYBACK_SPEED,
VIDEO_SCALE,
SUBTITLE_DELAY,
- BITRATE,
}
data class PlaybackSettings(
@@ -76,7 +73,6 @@ data class PlaybackSettings(
val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: Boolean,
- val maxBitrate: Long,
)
/**
@@ -166,13 +162,6 @@ fun PlaybackDialog(
),
)
}
- add(
- BottomDialogItem(
- data = PlaybackDialogType.BITRATE,
- headline = stringResource(R.string.max_bitrate),
- supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) },
- ),
- )
add(
BottomDialogItem(
data = PlaybackDialogType.DEBUG,
@@ -282,29 +271,6 @@ fun PlaybackDialog(
}
}
}
-
- PlaybackDialogType.BITRATE -> {
- val choices =
- AppPreference.bitrateValues.map { bitrate ->
- BottomDialogItem(
- data = bitrate,
- headline = formatMaxBitrate(bitrate),
- supporting = null,
- )
- }
- BottomDialog(
- choices = choices,
- currentChoice = choices.firstOrNull { it.data == settings.maxBitrate },
- onDismissRequest = {
- onControllerInteraction.invoke()
- onDismissRequest.invoke()
- },
- onSelectChoice = { _, choice ->
- onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data))
- },
- gravity = leftGravity,
- )
- }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 86d64651..483083f2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -322,10 +322,6 @@ fun PlaybackPageContent(
player.seekToPrevious()
}
}
-
- is PlaybackAction.ChangeBitrate -> {
- viewModel.changeMaxBitrate(it.maxBitrate)
- }
}
}
@@ -756,7 +752,6 @@ fun PlaybackPageContent(
// TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
- maxBitrate = state.maxBitrate,
),
onDismissRequest = {
playbackDialog =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
index cca3a0d0..b42b7151 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
@@ -24,7 +24,6 @@ data class PlaybackState(
val nextUp: BaseItem? = null,
val playlistIndex: Int = 0,
val playlist: Playlist = Playlist(emptyList()),
- val maxBitrate: Long = -1L,
) {
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
val hasPrevious: Boolean get() = playlistIndex > 0
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 24bd8ee9..012038a8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -5,7 +5,6 @@ import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
-import androidx.annotation.VisibleForTesting
import androidx.compose.ui.unit.Density
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
@@ -35,7 +34,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreference
@@ -200,7 +198,7 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDtoFlow
- @VisibleForTesting
+ // Exposed for testing
val initJob: Job
init {
@@ -292,16 +290,8 @@ class PlaybackViewModel
*/
private suspend fun init() {
musicService.stop()
+ _state.update { it.copy(nextUp = null) }
this.preferences = userPreferencesService.getCurrent()
- val maxBitrate =
- preferences.appPreferences.playbackPreferences.maxBitrate
- .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
- _state.update {
- it.copy(
- nextUp = null,
- maxBitrate = maxBitrate,
- )
- }
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
}
@@ -499,16 +489,7 @@ class PlaybackViewModel
null
}
- val audioStc =
- streamChoiceService.getSeriesTrackChoices(
- base,
- SeriesTrackChoiceType.AUDIO,
- )
- val subtitleStc =
- streamChoiceService.getSeriesTrackChoices(
- base,
- SeriesTrackChoiceType.AUDIO,
- )
+ val plc = streamChoiceService.getPlaybackLanguageChoice(base)
if (mediaSource == null && !isLiveTv) {
showToast(
@@ -537,31 +518,30 @@ class PlaybackViewModel
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
- val audioResult =
+ val audioStream =
mediaSource?.let {
streamChoiceService
.chooseAudioStream(
source = mediaSource,
- item = base,
+ seriesId = base.seriesId,
itemPlayback = itemPlayback,
- stc = audioStc,
+ plc = plc,
prefs = preferences,
)
}
- val audioIndex = audioResult?.stream?.index
+ val audioIndex = audioStream?.index
val subtitleIndex =
mediaSource?.let {
streamChoiceService
.chooseSubtitleStream(
- item = base,
source = mediaSource,
- audioStream = audioResult?.stream,
+ audioStream = audioStream,
+ seriesId = base.seriesId,
itemPlayback = itemPlayback,
- stc = subtitleStc,
+ plc = plc,
prefs = preferences,
- )?.stream
- ?.index
+ )?.index
}
Timber.d(
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
@@ -636,7 +616,6 @@ class PlaybackViewModel
positionMs: Long = 0,
enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding,
- maxBitrate: Long = this.state.value.maxBitrate,
): Unit =
withContext(WholphinDispatchers.IO) {
val itemId = item.id
@@ -647,8 +626,7 @@ class PlaybackViewModel
state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
- enableDirectPlay &&
- state.value.maxBitrate == maxBitrate
+ enableDirectPlay
) {
val wasSuccessful =
changeStreamsDirectPlay(
@@ -661,17 +639,18 @@ class PlaybackViewModel
}
Timber.i(
- "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s",
+ "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
itemId,
audioIndex,
subtitleIndex,
enableDirectPlay,
enableDirectStream,
positionMs,
- maxBitrate,
)
- _state.update { it.copy(maxBitrate = maxBitrate) }
+ val maxBitrate =
+ preferences.appPreferences.playbackPreferences.maxBitrate
+ .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by
api.mediaInfoApi
.getPostedPlaybackInfo(
@@ -717,7 +696,6 @@ class PlaybackViewModel
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = source.id,
- liveStreamId = source.liveStreamId,
static = true,
tag = source.eTag,
playSessionId = response.playSessionId,
@@ -977,7 +955,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = index,
- item = currentItem.item.data,
+ seriesId = currentItem.item.data.seriesId,
subtitleIndex = currentPlayback.subtitleIndex,
prefs = preferences,
)
@@ -1013,7 +991,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = currentPlayback.audioIndex,
- item = currentItem.item.data,
+ seriesId = currentItem.item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
@@ -1031,26 +1009,6 @@ class PlaybackViewModel
}
}
- fun changeMaxBitrate(newMaxBitrate: Long) {
- viewModelScope.launchIO {
- val currentPlayback = state.value.currentPlayback
- if (currentPlayback != null) {
- Timber.d("Changing bitrate to %s", newMaxBitrate)
- changeStreams(
- item = currentItem.item,
- sourceId = currentPlayback.mediaSourceInfo.id,
- audioIndex = currentPlayback.audioIndex,
- subtitleIndex = currentPlayback.subtitleIndex,
- positionMs = onMain { player.currentPosition },
- enableDirectPlay = true,
- maxBitrate = newMaxBitrate,
- )
- } else {
- Timber.w("Trying to change bitrate, but currentPlayback is null")
- }
- }
- }
-
internal suspend fun saveTrackSelection(
trackIndex: Int,
type: MediaStreamType,
@@ -1152,7 +1110,7 @@ class PlaybackViewModel
}
// Variables for tracking segment state
- @VisibleForTesting
+ // Exposed for testing
internal var segmentJob: Job? = null
private val autoSkippedSegments = mutableSetOf()
private val outroShownSegments = mutableSetOf()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
index 45fcdd4c..ba77d8b7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -123,10 +122,6 @@ sealed interface PlaybackAction {
data object Previous : PlaybackAction
data object Next : PlaybackAction
-
- data class ChangeBitrate(
- val maxBitrate: Long,
- ) : PlaybackAction
}
/**
@@ -566,19 +561,12 @@ fun BottomDialog(
gravity: Int,
currentChoice: BottomDialogItem? = null,
) {
- val initialIndex =
- remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0
- val listState =
- rememberLazyListState(
- initialFirstVisibleItemIndex = initialIndex,
- )
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
- val bringIntoViewRequesters =
- remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
if (currentChoice != null) {
LaunchedEffect(Unit) {
- bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
- focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
+ choices.indexOfFirstOrNull { it == currentChoice }?.let {
+ focusRequesters.getOrNull(it)?.tryRequestFocus()
+ }
}
}
// TODO enforcing a width ends up ignore the gravity
@@ -603,7 +591,6 @@ fun BottomDialog(
),
) {
LazyColumn(
- state = listState,
modifier =
Modifier
.fillMaxWidth()
@@ -637,10 +624,7 @@ fun BottomDialog(
}
},
interactionSource = interactionSource,
- modifier =
- Modifier
- .bringIntoViewRequester(bringIntoViewRequesters[index])
- .focusRequester(focusRequesters[index]),
+ modifier = Modifier.focusRequester(focusRequesters[index]),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
index 81f4833b..0a843dcb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
@@ -1,13 +1,6 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.res.stringResource
-import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
-import com.github.damontecres.wholphin.data.model.SeerrServer
-import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.preferences.AppPreference
import kotlinx.serialization.Serializable
@@ -45,53 +38,9 @@ enum class PreferenceScreenOption {
SCREENSAVER,
SKIP_SEGMENTS,
EXPERIMENTAL,
- HDR_FORMATS,
;
companion object {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
}
}
-
-@Composable
-fun rememberSeerrDialogBody(
- user: SeerrUser,
- server: SeerrServer,
- pluginProvided: Boolean,
- ex: Exception? = null,
-): String {
- val username =
- when (user.authMethod) {
- SeerrAuthMethod.LOCAL,
- SeerrAuthMethod.JELLYFIN,
- -> user.username ?: stringResource(R.string.unknown)
-
- SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key)
- }
- val method =
- when (user.authMethod) {
- SeerrAuthMethod.LOCAL -> R.string.seerr_local_user
- SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user
- SeerrAuthMethod.API_KEY -> 0
- }.let {
- if (it != 0) " (${stringResource(it)})" else ""
- }
-
- val body =
- """
- ${stringResource(R.string.url)}: ${server.url}
- ${stringResource(R.string.username)}: $username$method
- """.trimIndent() +
- if (pluginProvided) {
- "\n\n${stringResource(R.string.managed_by_server)}"
- } else {
- ""
- } +
- if (ex != null) {
- "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}"
- } else {
- ""
- }
-
- return remember { body }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index 0e388213..a21405c2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -36,12 +36,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
@@ -53,7 +51,6 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
-import com.github.damontecres.wholphin.preferences.HdrFormatPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
@@ -77,11 +74,9 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
-import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
-import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
-import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
+import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
@@ -103,7 +98,6 @@ fun PreferencesContent(
onFocus: (Int, Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
- val resources = LocalResources.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
@@ -116,11 +110,6 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
val players by viewModel.externalPlayers.collectAsState()
- val serverSupported by viewModel.serverSupported.collectAsState()
- val serverVersionMessage =
- remember(resources, serverSupported) {
- resources.serverSupportedMessage(serverSupported.second, serverSupported.first)
- }
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@@ -166,7 +155,6 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
- PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@@ -177,7 +165,6 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
- PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats
}
var visible by remember { mutableStateOf(false) }
@@ -237,19 +224,6 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
- serverVersionMessage?.let { message ->
- item {
- ListItem(
- selected = false,
- enabled = true,
- onClick = {},
- headlineContent = {
- PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
- },
- modifier = Modifier,
- )
- }
- }
if (showUpdate) {
item {
val interactionSource = remember { MutableInteractionSource() }
@@ -285,7 +259,6 @@ fun PreferencesContent(
)
}
}
-
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
@@ -502,11 +475,10 @@ fun PreferencesContent(
title = stringResource(pref.title),
onClick = {
seerrDialogMode =
- when (val conn = seerrConnection.connection) {
+ when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(
- conn.server,
- conn.user,
+ conn.server.url,
conn.ex,
)
}
@@ -517,20 +489,22 @@ fun PreferencesContent(
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
- conn.current,
+ conn.current.server.url,
)
}
}
},
modifier = focusModifier,
summary =
- when (seerrConnection.connection) {
+ when (seerrConnection) {
is SeerrConnectionStatus.Error -> {
stringResource(R.string.voice_error_server)
}
SeerrConnectionStatus.NotConfigured -> {
- stringResource(R.string.add_server)
+ stringResource(
+ R.string.add_server,
+ )
}
is SeerrConnectionStatus.Success -> {
@@ -725,14 +699,63 @@ fun PreferencesContent(
)
}
}
- }
+ when (val mode = seerrDialogMode) {
+ is SeerrDialogMode.Remove -> {
+ ConfirmDialog(
+ title = stringResource(R.string.remove_seerr_server),
+ body = mode.serverUrl,
+ onCancel = { seerrDialogMode = SeerrDialogMode.None },
+ onConfirm = {
+ seerrVm.removeServer()
+ seerrDialogMode = SeerrDialogMode.None
+ },
+ )
+ }
- SeerrDialog(
- seerrDialogMode = seerrDialogMode,
- seerrConnection = seerrConnection,
- onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
- seerrVm = seerrVm,
- )
+ SeerrDialogMode.Add -> {
+ val currentUser by seerrVm.currentUser.collectAsState(null)
+ val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
+ val serverAddedMessage = stringResource(R.string.seerr_server_added)
+ LaunchedEffect(status) {
+ if (status == LoadingState.Success) {
+ Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
+ seerrDialogMode = SeerrDialogMode.None
+ }
+ }
+ AddSeerServerDialog(
+ currentUsername = currentUser?.name,
+ status = status,
+ onSubmit = seerrVm::submitServer,
+ onResetStatus = seerrVm::resetStatus,
+ onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
+ )
+ }
+
+ is SeerrDialogMode.Error -> {
+ val errorStr = stringResource(R.string.voice_error_server)
+ val body =
+ remember(mode) {
+ """
+ ${mode.serverUrl}
+
+ $errorStr: ${mode.ex.localizedMessage}
+ """.trimIndent()
+ }
+ ConfirmDialog(
+ title = stringResource(R.string.remove_seerr_server),
+ body = body,
+ onCancel = { seerrDialogMode = SeerrDialogMode.None },
+ onConfirm = {
+ seerrVm.removeServer()
+ seerrDialogMode = SeerrDialogMode.None
+ },
+ bodyColor = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ SeerrDialogMode.None -> {}
+ }
+ }
if (showQuickConnectDialog) {
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
@@ -815,7 +838,6 @@ fun PreferencesPage(
PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS,
PreferenceScreenOption.EXPERIMENTAL,
- PreferenceScreenOption.HDR_FORMATS,
-> {
PreferencesContent(
initialPreferences,
@@ -835,3 +857,18 @@ data class CacheUsage(
val imageMemoryMax: Long,
val imageDiskUsed: Long,
)
+
+private sealed interface SeerrDialogMode {
+ data object None : SeerrDialogMode
+
+ data object Add : SeerrDialogMode
+
+ data class Remove(
+ val serverUrl: String,
+ ) : SeerrDialogMode
+
+ data class Error(
+ val serverUrl: String,
+ val ex: Exception,
+ ) : SeerrDialogMode
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 2e0ce392..200bac4e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -12,7 +12,6 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -26,7 +25,6 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.launchIO
-import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@@ -35,7 +33,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
@@ -79,23 +76,6 @@ class PreferencesViewModel
val externalPlayers = MutableStateFlow>(emptyList())
- val serverSupported =
- serverRepository.currentServerFlow
- .map {
- val serverVersion = it?.serverVersion
- serverVersion to
- when {
- serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED
- serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED
- serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
- else -> ServerVersionSupported.SUPPORTED
- }
- }.stateIn(
- viewModelScope,
- SharingStarted.Eagerly,
- null to ServerVersionSupported.SUPPORTED,
- )
-
init {
viewModelScope.launchIO {
val fakeIntent =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
index bd14c38c..81cc7c2a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
@@ -32,7 +32,6 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
-import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import org.jellyfin.sdk.model.api.PublicSystemInfo
@@ -178,12 +177,7 @@ fun ServerIconCard(
Icon(
imageVector = Icons.Default.Warning,
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
- tint =
- if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
- AppColors.GoldenYellow
- } else {
- MaterialTheme.colorScheme.errorContainer
- },
+ tint = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.size(cardSize * 0.3f),
)
Text(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
index dd972f9b..c25fe776 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
@@ -11,7 +11,6 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -67,12 +66,11 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
+import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.rememberInt
-import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
-import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
@Composable
@@ -103,8 +101,6 @@ private fun SwitchServerContentInternal(
modifier: Modifier = Modifier,
) {
val resources = LocalResources.current
- val focusRequesters =
- remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } }
var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(null) }
var focusedIndex by rememberInt(0)
@@ -148,13 +144,10 @@ private fun SwitchServerContentInternal(
val firstServerFocus = remember { FocusRequester() }
LaunchedEffect(state.loading) {
val state = state
- when (state.loading) {
- is LoadingState.Error,
- LoadingState.Loading,
- LoadingState.Pending,
- -> Unit
-
- LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus()
+ if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) {
+ firstServerFocus.tryRequestFocus()
+ } else if (state.loading == LoadingState.Pending) {
+ firstServerFocus.tryRequestFocus()
}
}
LazyRow(
@@ -195,7 +188,10 @@ private fun SwitchServerContentInternal(
Modifier
.onFocusChanged {
if (it.isFocused) focusedIndex = index
- }.focusRequester(focusRequesters[index]),
+ }.ifElse(
+ index == 0,
+ Modifier.focusRequester(firstServerFocus),
+ ),
)
}
// Add Server card - always rightmost
@@ -206,7 +202,10 @@ private fun SwitchServerContentInternal(
Modifier
.onFocusChanged {
if (it.isFocused) focusedIndex = -1
- }.focusRequester(focusRequesters.last()),
+ }.ifElse(
+ state.servers.isEmpty(),
+ Modifier.focusRequester(firstServerFocus),
+ ),
)
}
}
@@ -236,7 +235,20 @@ private fun SwitchServerContentInternal(
.fillMaxWidth()
.align(Alignment.BottomCenter),
) { message ->
- SwitchErrorMessage(message ?: "")
+
+ Text(
+ text = message ?: "",
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
+ .align(Alignment.BottomCenter),
+ )
}
// Delete server dialog
@@ -315,12 +327,12 @@ private fun AddServerDialog(
}
}
- val buttonFocusRequester = remember { FocusRequester() }
+ val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
- LaunchedEffect(showEnterAddress) {
- if (!showEnterAddress) {
- buttonFocusRequester.tryRequestFocus()
+ LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) {
+ if (!showEnterAddress && state.discoveredServers.isNotEmpty()) {
+ firstDiscoveredServerFocusRequester.tryRequestFocus()
}
}
@@ -367,8 +379,12 @@ private fun AddServerDialog(
key = { state.discoveredServers[it].url },
) { index ->
val server = state.discoveredServers[index]
- val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() }
+ val focusRequester =
+ if (index == 0) {
+ firstDiscoveredServerFocusRequester
+ } else {
+ remember { FocusRequester() }
+ }
ListItem(
enabled = true,
@@ -475,38 +491,21 @@ fun serverErrorMessage(
server: ServerState?,
resources: Resources,
): String? =
- if (server == null || server.status == ServerConnectionStatus.Pending) {
- null
- } else if (server.status is ServerConnectionStatus.Success) {
- resources.serverSupportedMessage(
- server.versionSupported,
- server.status.systemInfo.version
- ?.let { ServerVersion.fromString(it) },
- )
- } else if (server.status is ServerConnectionStatus.Error) {
- server.status.message
- } else {
- resources.serverSupportedMessage(
- server.versionSupported,
- null,
- )
- }
+ when {
+ server?.status is ServerConnectionStatus.Error -> {
+ server.status.message
+ }
-@Composable
-fun BoxScope.SwitchErrorMessage(message: String?) {
- message?.let {
- Text(
- text = message,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
- .align(Alignment.BottomCenter),
- )
+ server?.status is ServerConnectionStatus.Success &&
+ server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
+ resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}"
+ }
+
+ server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
+ resources.getString(R.string.server_version_not_supported)
+ }
+
+ else -> {
+ null
+ }
}
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
index 00beccb3..53190665 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
@@ -97,8 +97,6 @@ class SwitchServerViewModel
result.systemInfo.version?.let { ServerVersion.fromString(it) }
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED
- } else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
- ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else {
ServerVersionSupported.SUPPORTED
}
@@ -309,5 +307,4 @@ enum class ServerVersionSupported {
SUPPORTED,
NOT_SUPPORTED,
UNKNOWN,
- NOT_SUPPORTED_IN_FUTURE,
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
index cfe70890..86cf718d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -49,7 +50,6 @@ import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@@ -183,15 +183,37 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(),
)
}
- val resources = LocalResources.current
- val message =
- remember(resources, state.serverVersionSupported) {
- resources.serverSupportedMessage(
- state.serverVersionSupported,
- state.serverVersion,
+ when (state.serverVersionSupported) {
+ ServerVersionSupported.SUPPORTED -> {}
+
+ ServerVersionSupported.NOT_SUPPORTED,
+ ServerVersionSupported.UNKNOWN,
+ -> {
+ val resources = LocalResources.current
+ val message =
+ remember(resources) {
+ if (state.serverVersion.isNotNullOrBlank()) {
+ resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}"
+ } else {
+ resources.getString(R.string.server_version_not_supported) + ": " +
+ resources.getString(R.string.unknown)
+ }
+ }
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
+ .align(Alignment.BottomCenter),
)
}
- SwitchErrorMessage(message)
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
index b11b0b19..e1a6dd96 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
@@ -300,18 +300,16 @@ class SwitchUserViewModel
knownUsers + publicUsers
}
- private suspend fun checkServerVersion(): Pair {
+ private suspend fun checkServerVersion(): Pair {
val api = jellyfin.createApi(server.url)
val systemInfo by api.systemApi.getPublicSystemInfo()
val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) }
return if (serverVersion == null) {
- serverVersion to ServerVersionSupported.UNKNOWN
+ systemInfo.version to ServerVersionSupported.UNKNOWN
} else if (serverVersion < WholphinApplication.minimumServerVersion) {
- serverVersion to ServerVersionSupported.NOT_SUPPORTED
- } else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
- serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
+ systemInfo.version to ServerVersionSupported.NOT_SUPPORTED
} else {
- serverVersion to ServerVersionSupported.SUPPORTED
+ systemInfo.version to ServerVersionSupported.SUPPORTED
}
}
@@ -343,7 +341,7 @@ data class SwitchUserState(
// LoadingState for while adding/switching users
val switchUserState: LoadingState = LoadingState.Pending,
val loginAttempts: Int = 0,
- val serverVersion: ServerVersion? = null,
+ val serverVersion: String? = null,
val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
index 5faccc05..59c9b2ae 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
@@ -42,7 +42,6 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerrServerApiKey(
- initialUrl: String,
onSubmit: (url: String, apiKey: String) -> Unit,
status: LoadingState,
modifier: Modifier = Modifier,
@@ -56,18 +55,12 @@ fun AddSeerrServerApiKey(
.padding(16.dp)
.wrapContentSize(),
) {
- var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
+ var url by remember { mutableStateOf(seerrUrlPrefill()) }
var apiKey by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- if (url.isBlank()) {
- focusRequester.tryRequestFocus()
- } else {
- passwordFocusRequester.tryRequestFocus()
- }
- }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = stringResource(R.string.enter_url_api_key),
style = MaterialTheme.typography.titleMedium,
@@ -160,7 +153,6 @@ fun AddSeerrServerApiKey(
@Composable
fun AddSeerrServerUsername(
- initialUrl: String,
onSubmit: (url: String, username: String, password: String) -> Unit,
username: String,
status: LoadingState,
@@ -175,22 +167,14 @@ fun AddSeerrServerUsername(
.padding(16.dp)
.wrapContentSize(),
) {
- var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
+ var url by remember { mutableStateOf(seerrUrlPrefill()) }
var username by remember { mutableStateOf(username) }
var password by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- val focusRequesterToUse =
- when {
- url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester
- url.isNotNullOrBlank() -> usernameFocusRequester
- else -> focusRequester
- }
- focusRequesterToUse.tryRequestFocus()
- }
+ LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = stringResource(R.string.username_or_password),
style = MaterialTheme.typography.titleMedium,
@@ -334,7 +318,6 @@ fun AddSeerrServerUsername(
private fun AddSeerrServerUsernamePreview() {
WholphinTheme {
AddSeerrServerUsername(
- initialUrl = "",
onSubmit = { string: String, string1: String, string2: String -> },
username = "test",
status = LoadingState.Pending,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
index 5be93b83..bb38aabb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
@@ -1,161 +1,27 @@
package com.github.damontecres.wholphin.ui.setup.seerr
-import android.widget.Toast
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.tv.material3.MaterialTheme
-import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
-import com.github.damontecres.wholphin.data.model.SeerrServer
-import com.github.damontecres.wholphin.data.model.SeerrUser
-import com.github.damontecres.wholphin.services.CurrentSeerr
-import com.github.damontecres.wholphin.services.SeerrState
import com.github.damontecres.wholphin.ui.components.BasicDialog
-import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
-import com.github.damontecres.wholphin.ui.components.LoadingPage
-import com.github.damontecres.wholphin.ui.components.ScrollableDialog
-import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody
import com.github.damontecres.wholphin.util.LoadingState
-@Composable
-fun SeerrDialog(
- seerrDialogMode: SeerrDialogMode,
- seerrConnection: SeerrState,
- onDismissRequest: () -> Unit,
- seerrVm: SwitchSeerrViewModel = hiltViewModel(),
-) {
- val context = LocalContext.current
- when (val mode = seerrDialogMode) {
- is SeerrDialogMode.Remove -> {
- val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided)
- if (seerrConnection.pluginProvided) {
- ScrollableDialog(
- onDismissRequest = onDismissRequest,
- ) {
- item {
- Text(body)
- }
- }
- } else {
- ConfirmDialog(
- title = stringResource(R.string.remove_seerr_server),
- body = body,
- onCancel = onDismissRequest,
- onConfirm = {
- seerrVm.removeServer()
- onDismissRequest.invoke()
- },
- elevation = 3.dp,
- )
- }
- }
-
- SeerrDialogMode.Add -> {
- val currentUser by seerrVm.currentUser.collectAsState(null)
- val state by seerrVm.state.collectAsState()
- val serverAddedMessage = stringResource(R.string.seerr_server_added)
- LaunchedEffect(state.serverConnectionStatus) {
- if (state.serverConnectionStatus == LoadingState.Success) {
- Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
- onDismissRequest.invoke()
- }
- }
- when (state.loading) {
- is LoadingState.Error,
- LoadingState.Loading,
- LoadingState.Pending,
- -> {
- BasicDialog(
- onDismissRequest = onDismissRequest,
- ) {
- LoadingPage(Modifier.padding(16.dp))
- }
- }
-
- LoadingState.Success -> {
- AddSeerServerDialog(
- initialUrl = state.initialUrl,
- currentUsername = currentUser?.name,
- status = state.serverConnectionStatus,
- onSubmit = seerrVm::submitServer,
- onResetStatus = seerrVm::resetStatus,
- onDismissRequest = onDismissRequest,
- )
- }
- }
- }
-
- is SeerrDialogMode.Error -> {
- val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex)
- if (seerrConnection.pluginProvided) {
- ConfirmDialog(
- title = stringResource(R.string.error),
- body = body,
- onCancel = onDismissRequest,
- onConfirm = {
- seerrVm.retryServerFromPlugin()
- onDismissRequest.invoke()
- },
- bodyColor = MaterialTheme.colorScheme.error,
- elevation = 3.dp,
- confirmStringRes = R.string.retry,
- )
- } else {
- ConfirmDialog(
- title = stringResource(R.string.remove_seerr_server),
- body = body,
- onCancel = onDismissRequest,
- onConfirm = {
- seerrVm.removeServer()
- onDismissRequest.invoke()
- },
- bodyColor = MaterialTheme.colorScheme.error,
- elevation = 3.dp,
- )
- }
- }
-
- SeerrDialogMode.None -> {}
- }
-}
-
-sealed interface SeerrDialogMode {
- data object None : SeerrDialogMode
-
- data object Add : SeerrDialogMode
-
- data class Remove(
- val current: CurrentSeerr,
- ) : SeerrDialogMode
-
- data class Error(
- val server: SeerrServer,
- val user: SeerrUser,
- val ex: Exception,
- ) : SeerrDialogMode
-}
-
@Composable
fun AddSeerServerDialog(
currentUsername: String?,
- initialUrl: String?,
status: LoadingState,
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onResetStatus: () -> Unit,
@@ -176,7 +42,6 @@ fun AddSeerServerDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
AddSeerrServerUsername(
- initialUrl = initialUrl ?: "",
onSubmit = { url, username, password ->
onSubmit.invoke(url, username, password, auth)
},
@@ -192,7 +57,6 @@ fun AddSeerServerDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerApiKey(
- initialUrl = initialUrl ?: "",
onSubmit = { url, apiKey ->
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
index 2fde3eae..773f6c74 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
@@ -3,13 +3,11 @@ package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
-import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
@@ -17,7 +15,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -31,29 +28,11 @@ class SwitchSeerrViewModel
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
- private val serverPluginApi: ServerPluginApi,
) : ViewModel() {
val currentUser = serverRepository.currentUserFlow
val currentSeerrServer = seerrServerRepository.currentServer
- private val _state = MutableStateFlow(SwitchSeerrState())
- val state: StateFlow = _state
-
- init {
- if (serverRepository.serverPluginInstalled.value) {
- viewModelScope.launchIO {
- try {
- serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl ->
- _state.update { it.copy(initialUrl = serverUrl) }
- }
- } catch (ex: Exception) {
- Timber.e(ex, "Error fetching seerr settings")
- } finally {
- _state.update { it.copy(loading = LoadingState.Success) }
- }
- }
- }
- }
+ val serverConnectionStatus = MutableStateFlow(LoadingState.Pending)
fun submitServer(
url: String,
@@ -62,21 +41,13 @@ class SwitchSeerrViewModel
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
- _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) }
+ serverConnectionStatus.update { LoadingState.Loading }
val urls =
try {
createUrls(url)
} catch (ex: IllegalArgumentException) {
showToast(context, "Invalid URL")
- _state.update {
- it.copy(
- serverConnectionStatus =
- LoadingState.Error(
- "Invalid URL",
- ex,
- ),
- )
- }
+ serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO
}
Timber.v("Urls to try: %s", urls)
@@ -127,7 +98,7 @@ class SwitchSeerrViewModel
)
}
}
- _state.update { it.copy(serverConnectionStatus = LoadingState.Success) }
+ serverConnectionStatus.update { LoadingState.Success }
} else {
val message =
results
@@ -136,7 +107,7 @@ class SwitchSeerrViewModel
"$url - ${s?.localizedMessage}"
}.joinToString("\n")
showToast(context, "Could not connect")
- _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) }
+ serverConnectionStatus.update { LoadingState.Error(message) }
}
}
}
@@ -150,31 +121,11 @@ class SwitchSeerrViewModel
}
}
- fun retryServerFromPlugin() {
- viewModelScope.launchIO {
- val settings = serverPluginApi.fetchSeerrSettings()
- if (settings != null) {
- val result = seerrServerRepository.usePluginProvidedServer(settings)
- if (!result) {
- showToast(context, context.getString(R.string.voice_error_server))
- }
- } else {
- seerrServerRepository.clear()
- }
- }
- }
-
fun resetStatus() {
- _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) }
+ serverConnectionStatus.update { LoadingState.Pending }
}
}
-data class SwitchSeerrState(
- val loading: LoadingState = LoadingState.Pending,
- val serverConnectionStatus: LoadingState = LoadingState.Pending,
- val initialUrl: String? = null,
-)
-
fun createUrls(url: String): List {
val urls = mutableListOf()
if (url.startsWith("http://") || url.startsWith("https://")) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
index 8172b049..e74ffdf2 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
@@ -20,8 +20,6 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService
-import com.github.damontecres.wholphin.services.StreamChoiceReason
-import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
@@ -143,16 +141,13 @@ class SlideshowViewModel
),
)
serverRepository.currentUser?.let { user ->
-
val filter =
- slideshowSettings.parentId?.let {
- playbackEffectDao
- .getPlaybackEffect(
- user.rowId,
- slideshowSettings.parentId,
- BaseItemKind.PHOTO_ALBUM,
- )?.videoFilter
- }
+ playbackEffectDao
+ .getPlaybackEffect(
+ user.rowId,
+ slideshowSettings.parentId,
+ BaseItemKind.PHOTO_ALBUM,
+ )?.videoFilter
if (filter != null) {
Timber.v("Got filter for album %s", slideshowSettings.parentId)
albumImageFilter = filter
@@ -234,21 +229,12 @@ class SlideshowViewModel
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
ChosenStreams(
itemPlayback = null,
- audioStc = emptyList(),
- subtitleStc = emptyList(),
+ plc = null,
itemId = image.id,
source = source,
videoStream = video,
- audioStream =
- StreamChoiceResult(
- audio,
- StreamChoiceReason.Unknown,
- ),
- subtitleStream =
- StreamChoiceResult(
- null,
- StreamChoiceReason.Unknown,
- ),
+ audioStream = audio,
+ subtitleStream = null,
subtitlesDisabled = false,
)
}
@@ -444,7 +430,7 @@ class SlideshowViewModel
fun saveGalleryFilter() {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
val vf = _imageFilter.value
- if (slideshowSettings.parentId != null) {
+ if (vf != null) {
albumImageFilter = vf
serverRepository.currentUser?.let { user ->
playbackEffectDao
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt
deleted file mode 100644
index 31f4f6fa..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.github.damontecres.wholphin.ui.util
-
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
-import com.github.damontecres.wholphin.data.model.GetItemsFilter
-import com.github.damontecres.wholphin.ui.data.SortAndDirection
-import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.util.BlockingList
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.serializer.toUUIDOrNull
-import timber.log.Timber
-import kotlin.random.Random
-
-/**
- * Navigate to a random item in the given list
- */
-suspend fun goToRandom(
- navigateTo: (Destination) -> Unit,
- parentId: String,
- filter: GetItemsFilter,
- sortAndDirection: SortAndDirection,
- recursive: Boolean,
- list: BlockingList,
-) {
- val randomIndex = Random.nextInt(list.size)
- val random = list.getBlocking(randomIndex)
-
- Timber.d("Got random item #%s: %s", randomIndex, random?.id)
- if (random != null) {
- val destination =
- if (random.type == BaseItemKind.PHOTO) {
- Destination.Slideshow(
- parentId = parentId.toUUIDOrNull(),
- index = randomIndex,
- filter =
- CollectionFolderFilter(
- filter = filter,
- ),
- sortAndDirection = sortAndDirection,
- recursive = recursive,
- startSlideshow = false,
- )
- } else {
- random.destination()
- }
- navigateTo(destination)
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
index 2f98fccc..750ba711 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
@@ -22,7 +22,6 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.GetProgramsDto
-import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
@@ -402,25 +401,6 @@ object GetArtistsHandler : RequestHandler {
): Response = api.artistsApi.getArtists((request))
}
-@Serializable
-object GetAlbumArtistsHandler : RequestHandler {
- override fun prepare(
- request: GetAlbumArtistsRequest,
- startIndex: Int,
- limit: Int,
- enableTotalRecordCount: Boolean,
- ): GetAlbumArtistsRequest =
- request.copy(
- startIndex = startIndex,
- limit = limit,
- )
-
- override suspend fun execute(
- api: ApiClient,
- request: GetAlbumArtistsRequest,
- ): Response = api.artistsApi.getAlbumArtists(request)
-}
-
val requestSerializersModule =
SerializersModule {
polymorphic(Any::class) {
@@ -436,7 +416,6 @@ val requestSerializersModule =
subclass(GetStudiosRequest::class)
subclass(GetRecordingsRequest::class)
subclass(GetLiveTvChannelsRequest::class)
- subclass(GetArtistsRequest::class)
- subclass(GetAlbumArtistsRequest::class)
+ subclass(GetArtistsHandler::class)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
index d83ae269..c334967f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
@@ -1,7 +1,7 @@
package com.github.damontecres.wholphin.util
import android.content.Context
-import com.github.damontecres.wholphin.data.MostRecentServer
+import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService
@@ -13,7 +13,6 @@ import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderException
import org.acra.sender.ReportSenderFactory
import org.jellyfin.sdk.Jellyfin
-import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@@ -40,10 +39,26 @@ class CrashReportSender : ReportSender {
errorContent: CrashReportData,
) {
Timber.v("Attempting to send crash report")
- try {
- val recentServer = AppModule.mostRecentServerProvider(context).get()
- val api = recentServer.createApi(context)
- if (api != null) {
+ val prefs = ServerRepository.getServerSharedPreferences(context)
+ val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null)
+ val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null)
+ if (serverUrl != null && accessToken != null) {
+ try {
+ val okHttpClient =
+ OkHttpClient
+ .Builder()
+ .build()
+ val okHttpFactory = OkHttpFactory(okHttpClient)
+ val api =
+ createJellyfin {
+ this.context = context
+ clientInfo = AppModule.clientInfo(context)
+ deviceInfo = DeviceModule.deviceInfo(context)
+ apiClientFactory = okHttpFactory
+ socketConnectionFactory = okHttpFactory
+ minimumServerVersion = Jellyfin.minimumVersion
+ }.createApi(baseUrl = serverUrl, accessToken = accessToken)
+
val obj = JSONObject()
for ((key, value) in errorContent.toMap()) {
obj.put(key, value)
@@ -60,32 +75,13 @@ class CrashReportSender : ReportSender {
""".trimIndent() + jsonStr,
).content.fileName
- Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename)
+ Timber.i("Sent report to $serverUrl, filename=$filename")
}
- } else {
- throw ReportSenderException("Could not find valid server and/or credentials to use")
+ } catch (ex: Exception) {
+ throw ReportSenderException("Exception while sending crash report", ex)
}
- } catch (ex: Exception) {
- throw ReportSenderException("Exception while sending crash report", ex)
+ } else {
+ throw ReportSenderException("Could not find valid server and/or credentials to use")
}
}
}
-
-private fun MostRecentServer.createApi(context: Context): ApiClient? =
- (this as? MostRecentServer.ServerAndUser)?.let {
- val okHttpClient =
- OkHttpClient
- .Builder()
- .build()
- val okHttpFactory = OkHttpFactory(okHttpClient)
- val api =
- createJellyfin {
- this.context = context
- clientInfo = AppModule.clientInfo(context)
- deviceInfo = DeviceModule.deviceInfo(context)
- apiClientFactory = okHttpFactory
- socketConnectionFactory = okHttpFactory
- minimumServerVersion = Jellyfin.minimumVersion
- }.createApi(baseUrl = serverUrl, accessToken = accessToken)
- api
- }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt
deleted file mode 100644
index b4f8e15f..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-@file:OptIn(UnstableApi::class)
-
-package com.github.damontecres.wholphin.util
-
-import androidx.annotation.OptIn
-import androidx.media3.common.Format
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.util.UnstableApi
-import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
-import com.github.damontecres.wholphin.preferences.enabled
-import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
-
-/**
- * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this
- * device, and how a converted track is described.
- *
- * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and
- * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not
- * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer.
- * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the
- * single layer stream that decoder does switch for. Nothing is re-encoded.
- *
- * The conversion has two halves which have to agree:
- * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder;
- * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because
- * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would
- * otherwise never be chosen for it.
- *
- * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile.
- */
-object DolbyVisionProfile7Conversion {
- /**
- * True when both the user has enabled the setting and this device is one it helps: its Dolby
- * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself
- * is left alone, since it can use the enhancement layer the conversion would throw away, and
- * a device without a profile 8 decoder has nothing to handle the converted stream
- */
- fun isWanted(
- experimental: ExperimentalPreferences,
- mediaTest: MediaCodecCapabilitiesTest,
- ): Boolean =
- experimental.enabled { convertDolbyVisionProfile7 } &&
- !mediaTest.supportsHevcDolbyVisionEL() &&
- mediaTest.supportsHevcDolbyVisionProfile8()
-
- /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */
- fun isProfile7(format: Format): Boolean {
- val codecs = format.codecs ?: return false
- return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType &&
- (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07))
- }
-
- /**
- * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes
- * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they
- * are: their samples reach the decoder through the secure path the filter does not touch, so
- * advertising them as converted would be a lie.
- */
- fun advertiseAsProfile8(format: Format): Format =
- if (isProfile7(format) && format.drmInitData == null) {
- format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build()
- } else {
- format
- }
-
- /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */
- fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7)
-
- private const val CODECS_PREFIX_DVHE_07 = "dvhe.07"
- private const val CODECS_PREFIX_DVH1_07 = "dvh1.07"
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt
deleted file mode 100644
index 632f46c3..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-@file:OptIn(markerClass = [UnstableApi::class])
-
-package com.github.damontecres.wholphin.util
-
-import android.net.Uri
-import androidx.annotation.OptIn
-import androidx.media3.common.C
-import androidx.media3.common.Format
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.ExtractorsFactory
-import androidx.media3.extractor.ForwardingExtractor
-import androidx.media3.extractor.ForwardingExtractorOutput
-import androidx.media3.extractor.ForwardingExtractorsFactory
-import androidx.media3.extractor.ForwardingTrackOutput
-import androidx.media3.extractor.TrackOutput
-import timber.log.Timber
-
-/**
- * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what
- * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them.
- *
- * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not
- * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on
- * whichever decoder comes first in the list, with a warning that the format exceeds its
- * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8
- * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same
- * string.
- *
- * Only the codec string changes. Sample data is not touched here: the two convenience overloads of
- * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted.
- */
-class DolbyVisionProfile7ExtractorsFactory(
- delegate: ExtractorsFactory,
-) : ForwardingExtractorsFactory(delegate) {
- override fun createExtractors(): Array = wrap(super.createExtractors())
-
- override fun createExtractors(
- uri: Uri,
- responseHeaders: Map>,
- ): Array = wrap(super.createExtractors(uri, responseHeaders))
-
- private fun wrap(extractors: Array): Array =
- Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) }
-}
-
-private class Profile8AdvertisingExtractor(
- delegate: Extractor,
-) : ForwardingExtractor(delegate) {
- override fun init(output: ExtractorOutput) {
- super.init(Profile8AdvertisingExtractorOutput(output))
- }
-}
-
-private class Profile8AdvertisingExtractorOutput(
- output: ExtractorOutput,
-) : ForwardingExtractorOutput(output) {
- override fun track(
- id: Int,
- type: Int,
- ): TrackOutput {
- val trackOutput = super.track(id, type)
- return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput
- }
-}
-
-/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */
-internal class Profile8AdvertisingTrackOutput(
- delegate: TrackOutput,
-) : ForwardingTrackOutput(delegate) {
- override fun format(format: Format) {
- val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
- if (advertised !== format) {
- Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs)
- }
- super.format(advertised)
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt
deleted file mode 100644
index 7cf50622..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt
+++ /dev/null
@@ -1,313 +0,0 @@
-@file:OptIn(markerClass = [UnstableApi::class])
-
-package com.github.damontecres.wholphin.util
-
-import androidx.annotation.OptIn
-import androidx.media3.common.util.UnstableApi
-import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi
-import timber.log.Timber
-import java.nio.Buffer
-import java.nio.ByteBuffer
-
-/**
- * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is
- * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own.
- *
- * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long.
- * That is the shape the native code reads: it takes the buffer's address and capacity, not its
- * position and limit.
- */
-interface RpuRewriter {
- /**
- * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is
- * none it can parse.
- */
- fun profileOf(
- frame: ByteBuffer,
- size: Int,
- ): Int
-
- /**
- * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new
- * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's
- * capacity must leave room past [size] for an RPU which comes out longer than it went in.
- */
- fun convertToProfile8(
- frame: ByteBuffer,
- size: Int,
- ): Int
-}
-
-/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */
-class LibDoviRpuRewriter : RpuRewriter {
- private val libDovi = LibDovi()
-
- override fun profileOf(
- frame: ByteBuffer,
- size: Int,
- ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0
-
- override fun convertToProfile8(
- frame: ByteBuffer,
- size: Int,
- ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false)
-
- private companion object {
- /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */
- const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1
- }
-}
-
-/**
- * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder.
- *
- * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the
- * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU
- * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby
- * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a
- * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and
- * shows the HDR10 base layer, which is the very symptom this filter exists to fix.
- *
- * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is
- * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer
- * and is copied back only once it has succeeded and fits, so a refused access unit reaches the
- * decoder untouched rather than half rewritten.
- *
- * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is
- * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is
- * called from its playback thread only.
- */
-class DolbyVisionProfile7Filter(
- private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() },
-) : BitstreamFilter {
- private var rewriter: RpuRewriter? = null
- private var rewriterUnavailable = false
-
- /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */
- private var staging = ByteArray(0)
-
- /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */
- private var scratch: ByteBuffer? = null
-
- private var convertedCount = 0
- private var passedThroughCount = 0
- private var refusedCount = 0
-
- override fun filter(
- data: ByteBuffer,
- offset: Int,
- size: Int,
- ): Int {
- if (!startsWithStartCode(data, offset, size)) {
- return size
- }
- val rewriter = rewriter() ?: return size
-
- val staging = staging(size)
- window(data, offset, size).get(staging, 0, size)
- if (!carriesRpu(staging, size)) {
- passedThroughCount++
- return size
- }
-
- val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES)
- val kept = copyWithoutEnhancementLayer(staging, size, scratch)
- val converted =
- try {
- if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) {
- passedThroughCount++
- return size
- }
- rewriter.convertToProfile8(scratch, kept)
- } catch (e: RuntimeException) {
- refuse("the RPU rewrite threw", e)
- return size
- }
- if (converted <= 0 || converted > size) {
- refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null)
- return size
- }
-
- window(data, offset, converted).put(window(scratch, 0, converted))
- convertedCount++
- return converted
- }
-
- private fun rewriter(): RpuRewriter? {
- if (rewriterUnavailable) {
- return null
- }
- return rewriter ?: try {
- newRewriter().also { rewriter = it }
- } catch (e: LinkageError) {
- // No libdovi for this ABI. Playback goes on exactly as it did without the filter.
- Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is")
- rewriterUnavailable = true
- null
- }
- }
-
- private fun refuse(
- reason: String,
- e: Throwable?,
- ) {
- if (refusedCount == 0) {
- Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason)
- }
- refusedCount++
- }
-
- private fun staging(size: Int): ByteArray {
- if (staging.size < size) {
- staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES))
- }
- return staging
- }
-
- private fun scratch(capacity: Int): ByteBuffer {
- val current = scratch
- val buffer =
- if (current == null || current.capacity() < capacity) {
- ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it }
- } else {
- current
- }
- (buffer as Buffer).clear()
- return buffer
- }
-
- override fun toString(): String =
- "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " +
- "$passedThroughCount passed through, $refusedCount refused)"
-
- private companion object {
- const val DOLBY_VISION_PROFILE_7 = 7
-
- /** HEVC NAL unit type carrying the Dolby Vision RPU. */
- const val NAL_UNIT_TYPE_RPU = 62
-
- /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */
- const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63
-
- /** Big enough for a UHD access unit without regrowing on every early frame. */
- const val INITIAL_CAPACITY_BYTES = 512 * 1024
-
- /**
- * Room past the access unit for an RPU which comes out of the rewrite longer than it went
- * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose
- * enhancement layer the extractor never delivered has none to free.
- */
- const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024
-
- private fun startsWithStartCode(
- data: ByteBuffer,
- offset: Int,
- size: Int,
- ): Boolean {
- if (size < 4) {
- return false
- }
- return data.get(offset) == 0.toByte() &&
- data.get(offset + 1) == 0.toByte() &&
- (
- data.get(offset + 2) == 1.toByte() ||
- (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte())
- )
- }
-
- /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */
- private fun carriesRpu(
- source: ByteArray,
- length: Int,
- ): Boolean {
- var unit = 0
- while (unit < length) {
- val payload = unit + startCodeLength(source, unit)
- if (payload >= length) {
- return false
- }
- if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) {
- return true
- }
- unit = nextStartCode(source, payload, length)
- }
- return false
- }
-
- /**
- * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0,
- * leaving out every enhancement layer NAL unit, and returns the number of bytes copied.
- */
- fun copyWithoutEnhancementLayer(
- source: ByteArray,
- length: Int,
- target: ByteBuffer,
- ): Int {
- var unit = 0
- var written = 0
- while (unit < length) {
- val payload = unit + startCodeLength(source, unit)
- if (payload >= length) {
- // A start code with nothing behind it. Not something to interpret; kept as it came.
- target.put(source, unit, length - unit)
- written += length - unit
- break
- }
- val next = nextStartCode(source, payload, length)
- if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) {
- target.put(source, unit, next - unit)
- written += next - unit
- }
- unit = next
- }
- return written
- }
-
- /**
- * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so
- * that bulk copies leave the buffer's untouched. The position and limit calls go through
- * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23.
- */
- private fun window(
- data: ByteBuffer,
- offset: Int,
- length: Int,
- ): ByteBuffer {
- val view = data.duplicate()
- val buffer: Buffer = view
- buffer.clear()
- buffer.position(offset)
- buffer.limit(offset + length)
- return view
- }
-
- /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */
- private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F
-
- /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */
- private fun startCodeLength(
- data: ByteArray,
- at: Int,
- ): Int = if (data[at + 2] == 1.toByte()) 3 else 4
-
- /**
- * Index of the next Annex B start code at or after [from], or [limit] if there is none. A
- * fourth leading zero byte counts as part of the start code, which is the form media3's
- * extractors emit; three byte codes are walked all the same.
- */
- private fun nextStartCode(
- data: ByteArray,
- from: Int,
- limit: Int,
- ): Int {
- var i = from
- while (i + 2 < limit) {
- if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) {
- return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i
- }
- i++
- }
- return limit
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
index f1384a27..5c5004ac 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
@@ -12,6 +12,7 @@ import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
+import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
@@ -70,11 +71,10 @@ fun createDeviceProfile(
downMixAudio: Boolean,
assDirectPlay: Boolean,
pgsDirectPlay: Boolean,
+ dolbyVisionELDirectPlay: Boolean,
decodeAv1: Boolean,
jellyfinTenEleven: Boolean,
preferAc3ForSurround: Boolean,
- forceEnabledHdr: Set,
- forceDisabledHdr: Set,
maxResolution: Int,
) = buildDeviceProfile {
val allowedAudioCodecs =
@@ -148,9 +148,9 @@ fun createDeviceProfile(
videoCodec(Codec.Video.H264)
if (preferAc3ForSurround) {
- audioCodec(Codec.Audio.AC3)
- } else {
audioCodec(*allowedAudioCodecs)
+ } else {
+ audioCodec(Codec.Audio.AC3)
}
copyTimestamps = false
@@ -467,56 +467,60 @@ fun createDeviceProfile(
// / HDR exclude list
+ // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypesAv1 =
buildSet {
- if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName)
+ if (jellyfinTenEleven) add("DOVIInvalid")
if (!decodeAv1) {
if (!supportsAV1DolbyVision) {
- add(WholphinVideoRangeType.DOVI.serialName)
- if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
- if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
+ add(VideoRangeType.DOVI.serialName)
+ if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName)
+ if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus")
}
if (!supportsAV1HDR10Plus) {
- add(WholphinVideoRangeType.HDR10_PLUS.serialName)
+ add(VideoRangeType.HDR10_PLUS.serialName)
- if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName)
+ if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName)
}
}
- } - forceEnabledHdr + forceDisabledHdr
+ }
+ // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypesHevc =
buildSet {
- if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName)
+ if (jellyfinTenEleven) add("DOVIInvalid")
if (!supportsHevcDolbyVisionEL) {
- if (jellyfinTenEleven) {
- add(WholphinVideoRangeType.DOVI_WITH_EL.serialName)
- if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) {
- add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
+ if (!dolbyVisionELDirectPlay) {
+ if (jellyfinTenEleven) {
+ add("DOVIWithEL")
+ if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus")
}
}
if (!supportsHevcDolbyVision) {
- add(WholphinVideoRangeType.DOVI.serialName)
- if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
+ add(VideoRangeType.DOVI.serialName)
+ if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName)
if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) {
- add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
+ add(
+ "DOVIWithHDR10Plus",
+ )
}
}
}
if (!supportsHevcHDR10Plus) {
- add(WholphinVideoRangeType.HDR10_PLUS.serialName)
- if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName)
+ add(VideoRangeType.HDR10_PLUS.serialName)
+ if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName)
}
if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) {
- add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
- add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
+ add("DOVIWithHDR10Plus")
+ add("DOVIWithELHDR10Plus")
}
- } - forceEnabledHdr + forceDisabledHdr
+ }
// Display
// Note: The codec profiles use a workaround to create correct behavior
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
index a769c400..ab4de03e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
@@ -209,15 +209,6 @@ class MediaCodecCapabilitiesTest(
) &&
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
- // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces
- fun supportsHevcDolbyVisionProfile8(): Boolean =
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
- hasDecoder(
- MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
- DolbyVisionProfiles.Profile8,
- CodecProfileLevel.DolbyVisionLevelHd24,
- )
-
fun supportsHevcHDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt
deleted file mode 100644
index bd49a6c4..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.github.damontecres.wholphin.util.profile
-
-import org.jellyfin.sdk.model.api.VideoRangeType
-
-/**
- * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's
- * [VideoRangeType] enum (added server-side in Jellyfin 10.11).
- *
- * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+
- */
-enum class WholphinVideoRangeType(
- val serialName: String,
-) {
- UNKNOWN(VideoRangeType.UNKNOWN.serialName),
- SDR(VideoRangeType.SDR.serialName),
- HDR10(VideoRangeType.HDR10.serialName),
- HLG(VideoRangeType.HLG.serialName),
- DOVI(VideoRangeType.DOVI.serialName),
- DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName),
- DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName),
- DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName),
- HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName),
- DOVI_INVALID("DOVIInvalid"),
- DOVI_WITH_EL("DOVIWithEL"),
- DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"),
- DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"),
-}
diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto
index 55553166..0c3b5cb3 100644
--- a/app/src/main/proto/WholphinDataStore.proto
+++ b/app/src/main/proto/WholphinDataStore.proto
@@ -54,15 +54,10 @@ message PlaybackOverrides{
bool direct_play_ass = 3 [deprecated = true];
bool direct_play_pgs = 4;
MediaExtensionStatus media_extensions_enabled = 5;
- bool direct_play_dolby_vision_e_l = 6 [deprecated = true];
+ bool direct_play_dolby_vision_e_l = 6;
bool decode_av1 = 7;
AssPlaybackMode ass_playback_mode = 8;
int32 max_resolution = 9;
- HdrOverrideMode dovi_profile_5_override = 10;
- HdrOverrideMode dovi_profile_7_override = 11;
- HdrOverrideMode dovi_profile_8_override = 12;
- HdrOverrideMode hdr10_override = 13;
- HdrOverrideMode hdr10_plus_override = 14;
}
message PlaybackPreferences {
@@ -230,24 +225,18 @@ message MusicPreferences {
bool show_backdrop = 4;
}
-enum HdrOverrideMode{
- HDR_OVERRIDE_AUTO = 0;
- HDR_OVERRIDE_ENABLE = 1;
- HDR_OVERRIDE_DISABLE = 2;
-}
-
message ExperimentalPreferences {
bool enabled = 1;
bool video_tunneling_enabled = 2;
bool prefer_ac3_surround = 3;
bool disable_audio_offload = 4;
bool prefer_dolby_vision_over_hdr10_plus = 5;
- bool convert_dolby_vision_profile_7 = 6;
}
message AppPreferences {
- string current_server_id = 1 [deprecated = true];
- string current_user_id = 2 [deprecated = true];
+ // The currently signed in server and user IDs, mostly for restoring a session
+ string current_server_id = 1;
+ string current_user_id = 2;
PlaybackPreferences playback_preferences = 3;
HomePagePreferences home_page_preferences = 4;
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 7170a274..f4523d02 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -719,44 +719,4 @@
Von Wiedergabeliste entfernen
Anmeldeinformationen abgelaufen, bitte erneut anmelden
Du bist aktuell nicht berechtigt auf den Server zuzugreifen
- Dolby Vision gegenüber HDR10+ bevorzugen
- Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden
- Maximale Auflösung vor dem Transkodieren
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Folge
- - %1$d Folgen
-
- Am besten bewertet
- HDR Formate
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Benötigte Server Version: %1$s
- Benutzer
- Standard vom Server laden
- Nur auf diesem Gerät speichern
- Standard
- Geräte spezifisch
- Server Benutzerprofil
- Server Standard
- Benutzer Webclient
- Vom Server verwaltet
- Fehler
- Server Version wird bald nicht mehr unterstützt
- Festgelegt von %1$s
- Dolby Vision Profile 7 zu 8.1 konvertieren
- Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt
- Interpreten Quelle
- Reihen
- Aktivieren erzwingen
- Deaktivieren erzwingen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 714ae611..696ad927 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -741,19 +741,4 @@
Eliminar de la lista de reproducción
Credenciales caducadas, vuelve a iniciar sesión
No tienes autorización para acceder al servidor en este momento
- Preferir Dolby Vision sobre HDR10+
- Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+
- Resolución máxima antes de transcodificar
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Episodio
- - %1$d Episodios
- - %1$d Episodios
-
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml
index 2008bf2c..8fc29c91 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -719,44 +719,4 @@
Eemalda esitusloendist
Autentimiseandmed on aegunud, palun logi uuesti sisse
Sul pole hetkel õigusi ligipääsuks sellele serverile
- HDR10+ asemel eelista Dolby Visionit
- Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest
- Maksimaalne resolutsioon enne kodeeringu muutmist
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d osa
- - %1$d osa
-
- Parima hinnanguga
- HDR-vormingud
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Vajalik serveriversioon: %1$s
- Serveriversioon ei saa lähitulevikus olema toetatud
- Kasutaja
- Sai
- Vaikimisi laadi serverist
- Salvesta vaid siia seadmesse
- Vaikimisi
- Seadmekohane
- Kasutajaprofiil serveris
- Vaikimisi väärtus serveris
- Kasutaja veebiklient
- Valija: %1$s
- Jõusta sundkorras
- Lõpeta sundkorras jõustamine
- Hallatud serveri poolt
- Viga
- Teisenda Dolby Vision Profile 7 → 8.1
- Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7
- Esitaja allikas
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 85603d28..898f8a10 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -724,62 +724,4 @@
Livre audio
Version du serveur non prise en charge
Apparaît dans
- %1$dh %2$ds
- S%1$d E%2$d
- S%1$d E%2$d-E%3$d
- S%1$02dE%2$02d
- S%1$02dE%2$02d-E%3$02d
- E%1$d
- Saison %1$d
- Désactiver le délestage matériel audio
- La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils
- Préférez Dolby Vision à HDR10+
- Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+
- Écran en mode veille après un certain délai
- Pourcentage de réduction de luminosité
- Écran sombre
- Albums photos
- Aucun favori trouvé !
- Supprimer de la liste de lecture
- Vos identifiants ont expiré. Veuillez vous reconnecter
- Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment
- Résolution maximale avant le transcodage
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Épisode
- - %1$d Épisodes
- - %1$d Épisode
-
- Les mieux notés
- Formats HDR
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Activation forcée
- Désactivation forcée
- La version du serveur ne sera bientôt plus prise en charge
- Version requise du serveur : %1$s
- Sélectionné par %1$s
- Utilisateur
- Séries
- Charger les paramètres par défaut depuis le serveur
- Enregistrer uniquement sur cet appareil
- Défaut
- Spécifique à l\'appareil
- Profil utilisateur du serveur
- Paramètres par défaut du serveur
- Client Web utilisateur
- Convertir le profil Dolby Vision 7 en 8.1
- Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7
- Source de l\'artiste
- Géré par le serveur
- Erreur
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index f078a5f7..64d8c3b1 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -25,7 +25,7 @@
Rendező
Letiltva
Felfedezett szerverek
-
+ Letöltés & Frissítés
Letöltés folyamatban…
Engedélyezve
Adja meg a szerver IP-t vagy URL-t
@@ -43,7 +43,7 @@
Kiegészítő
Műfajok
Ugrás a sorozathoz
- Ugrás ide
+ Ugrás ide:
Lejátszási gombok elrejtése
Hibakeresési infó elrejtése
Elrejt
@@ -95,7 +95,7 @@
Újraindítás
Folytatás
Mentés
-
+ Keresés & Letöltés
Keresés
Keresés…
Szerver kiválasztása
@@ -121,7 +121,7 @@
Váltás
Legjobbra értékelt, nem megnézett
- - Előzetes
+ - Előzetesek
- Előzetesek
DVR Időzítés
@@ -138,7 +138,6 @@
Videók
Nézze élőben
- - %1$d éves
- %1$d éves
Lejátszás átalakítással
@@ -160,8 +159,8 @@
- Egyebek
- - A kulisszák mögött
- - A kulisszák mögött
+ - Színfalak mögött
+ - Színfalak mögött
- Főcímdal
@@ -201,19 +200,19 @@
- %s letöltés
- - %s letöltések
+ - %s letöltés
- %d óra
- - %d órák
+ - %d óra
- %d elem
- - %d elemek
+ - %d elem
- %d másodperc
- - %d másodpercek
+ - %d másodperc
%1$s-kor ér véget
Nem található szerver
@@ -235,7 +234,7 @@
Tartalom alapértelmezett méretezése
Frissítés telepítése
Telepített verzió
- A kezdőlap sorainak maximális elemszáma
+ Elemek száma soronként a kezdőlapon
Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve
Navigációs menü elemeinek testreszabása
Kattintson az oldalak közti váltáshoz
@@ -304,10 +303,10 @@
PIN-kód eltávolítása
Nézetbeállítások
Oszlopok
- Térköz
+ Helyköz
Képarány
- Részletek megjelenítése
- Képtípus
+ Részletek mutatása
+ Kép típusa
Vendégszereplők
Szegély mérete
Tartalomhoz igazodó képfrissítési gyakoriság
@@ -355,10 +354,10 @@
Felirat késleltetése
Háttér stílusa
Adja meg a szerver címét
-
+ Folytatás & Következő menü összevonása
Lejátszás backend
Kép-gyorsítótár mérete (MB)
-
+ Stábtagok
Navigációs menü oldalainak váltása fókusznál
Lejátszás felülbírálatai
Felbontásváltás
@@ -373,275 +372,4 @@
DTS
Csak kiegészítő felirat
Sávválasztás törlése
- %1$dó %2$dp %3$dmp
- %1$dó %2$dp
- %1$dó %2$dmp
- %1$dp %2$dmp
- %1$dó
- %1$dp
- %1$dmp
- Felhasználói profilbeállítások a szerverről
- Hangalapú keresés
- Indítás
- Keresés hanggal
- Feldolgozás
- A megszakításhoz nyomd meg a Vissza gombot
- Hangfelvételi hiba
- Hangfelismerési hiba
- Mikrofonengedély szükséges
- Hálózati hiba
- Hálózati időtúllépés
- Nem sikerült felismerni a beszédet
- A hangfelismerés foglalt
- Szerverhiba
- Nem észlelhető beszéd
- Ismeretlen hiba
- Nem sikerült elindítani a hangfelismerést
- A hangfelismerés időtúllépés miatt megszakadt
- Újrapróbálás
- Előzetesek
- %1$d. évad
- Egyéb
- Werkfilmek
- Főcímdalok
- Főcímvideók
- Klipek
- Törölt jelenetek
- Interjúk
- Jelenetek
- Minták
- Rövid werkfilmek
- Rövidfilmek
- Kedvenc %s
-
- - %s nap
- - %s napok
-
-
- - %s perc
- - %s percek
-
-
- - Film
- - Filmek
-
-
- - Sorozat
- - Sorozatok
-
-
- - Személy
- - Személyek
-
- Bejelentkezés szükséges a profil használatához
- [0–9]
- Hardveres hangfeldolgozás kikapcsolása
- A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat
- Összesített keresési találatok
- Az összes találat megjelenítése egyetlen listában
- Találatok megjelenítése kategóriánként
- Hangalapú keresés gomb megjelenítése
- Látható
- Rejtett
- Találatok
- Dolby Vision 7-es profil közvetlen lejátszása
- Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését
- A Dolby Vision előnyben részesítése a HDR10+-szal szemben
- Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket
- Felfedezés
- Kérés
- Függőben
- Seerr-integráció
- Seerr-szerver eltávolítása
- Seerr-szerver hozzáadva
- Gyors csatlakozás
- Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba
- Add meg a gyors csatlakozási kódot
- A kódnak 6 számjegyből kell állnia
- Az eszköz engedélyezése sikeres
- Jelszó
- Felhasználónév
- URL
- Népszerű most
- Hamarosan megjelenő filmek
- Hamarosan induló sorozatok
- Kérés 4K-ban
- AV1 szoftveres dekódolása
- Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod.
- HDR-feliratok stílusa
- Képalapú feliratok átlátszatlansága
- Fényerő
- Kontraszt
- Telítettség
- Színárnyalat
- Mentés albumhoz
- Diavetítés indítása
- Diavetítés leállítása
- Az elején
- Nincs több fénykép
- Forgatás balra
- Forgatás jobbra
- Nagyítás
- Kicsinyítés
- Diavetítés időtartama
- Videók lejátszása diavetítés közben
- Médiaadat-napló küldése a szervernek
- Korlátlan
- A „Következő” listában megjelenő napok maximális száma
- Albumok
- Előadók
- Dalok
- Ugrás az előadóhoz
- Most játszott tartalom
- Azonnali mix
- Ugrás az albumhoz
- Hozzáadás a lejátszási sorhoz
- Sor hozzáadása
- Műfajok %1$s
- Nemrég megjelent %1$s
- Magasság
- Alkalmazás az összes sorra
- Kezdőlap testreszabása
- Kezdőlap sorai
- Betöltés a szerver felhasználói profiljából
- Mentés a szerver felhasználói profiljába
- Betöltés a webes kliensből
- Sorozat képének használata
- Sor hozzáadása ehhez: %1$s
- Felülírod a szerveren lévő beállításokat?
- Felülírod a helyi beállításokat?
- Epizódokhoz
- Javaslatok ehhez: %1$s
- Az összes kártya méretének növelése
- Az összes kártya méretének csökkentése
- Bélyegképek használata
- Beállítások mentve
- Kijelző-beállítások
- Beépített előbeállítások az összes sor gyors formázásához
- Sorok és képek kiválasztása a kezdőlapon
-
- Térhatású hang használata AC3 formátumban
- Kezdés ennyi idő múlva
- Animálás
- Maximális korhatár
- Nincs felső korlát
- Minden korosztály számára
- %s éves korig
- Képernyővédő
- Képernyővédő beállítások
- Képernyővédő elindítása
- Időtartam
- Fotók
- Típusok felvétele
- Mely elemtípusokhoz jelenjenek meg képek
- Illesztés
- Kivágás
- Kitöltés
- Szélesség kitöltése
- Magasság kitöltése
- Wholphin alapértelmezett beállítása
- Wholphin kompakt elrendezés
- A sorozat miniatűrképei
- Az epizódok miniatűrképei
- Legalacsonyabb
- Alacsony
- Közepes
- Magas
- Teljes
- Lila
- Narancs
- Élénk kék
- Fekete
- Barna
- Kihagyás
- Automatikus átugrás
- Rákérdezés a kihagyásra
- Az FFmpeg használata csak akkor, ha nincs beépített dekóder
- Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben
- FFmpeg-dekóderek használatának tiltása
- A lejátszás végén
- HDR-formátumok
- HDR10
- HDR10+
- Dolby Vision 5-ös profil
- Dolby Vision 7-es profil
- Dolby Vision 8-as profil
- Kényszerített engedélyezés
- Kényszerített letiltás
- Piros
- Zöld
- Kék
- Elmosódás
- A stáblista és a zárójelenetek alatt
- Soha
- Fehér
- Világosszürke
- Sötétszürke
- Sárga
- Cián
- Magenta
- Körvonal
- Árnyék
- Sortörés
- Az MPV előnyben részesítése
- Külső lejátszó
- Az ExoPlayer használata HDR-lejátszáshoz
- Poszter (2:3)
- 16:9
- 4:3
- Négyzet (1:1)
- Elsődleges
- Bélyegkép
- Rács
- Lista
- Összefoglaló lista
- Dinamikus színű kép
- Csak kép
-
- API-kulcs
- Bejelentkezés a Seerr-szerverre
- Jellyfin-felhasználó
- Helyi felhasználó
-
- Nem található távoli felirat
- Jelenlegi
- Az alkalmazáson belüli képernyővédő használata
- Biztosan törölni szeretnéd ezt az elemet?
- Médiakezelési lehetőségek megjelenítése
- Keresés %s
- Színész
- Zeneszerző
- Író
- Vendégszereplő
- Producer
- Karmester
- Dalszövegíró
- Rendezés
- Mérnök
- Hangmérnök
- Alkotó
- Művész
- Album előadója
- Album
- Népszerű dalok
- Lejátszás következőként
- Videóklipek
- Dalszövegek
- Dalszöveg elrejtése
- Dalszöveg megjelenítése
- Eltávolítás a lejátszási sorból
- Albumborító megjelenítése
- Vizuális effektusok megjelenítése
- Háttér megjelenítése
- Lejátszás másként?
- A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést.
- Folytatás
- Összes kijelölése
- Típusok különválasztása
- A címek logóinak előnyben részesítése
- A Wholphin frissült a %s verzióra
- Eltávolítás a „Folytatás” listából
- Sorozat eltávolítása a „Következő epizód” listából
- A „Következő epizód” listából eltávolított sorozatok megtekintése
- További információk
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 623ef596..d6f20d4b 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -694,20 +694,4 @@
Redupkan layar
Album foto
Favorit tidak ditemukan!
-
- - %1$d episode
-
- Utamakan Dolby Vision dibanding HDR10+
- Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+
- Hapus dari playlist
- Kredensial kedaluwarsa, silakan login kembali
- Kamu tidak diizinkan mengakses server saat ini
- Resolusi maksimum sebelum transkode
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index be2f53e3..fb5f37ce 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -741,42 +741,4 @@
Rimuovi dalla playlist
Credenziali scadute, effettua di nuovo l’accesso
Non sei autorizzato ad accedere al server in questo momento
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Episodio
- - %1$d Episodi
- - %1$d Episodi
-
- Preferisci Dolby Vision a HDR10+
- Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+
- Risoluzione massima prima della trascodifica
- HDR10
- HDR10+
- I più votati
- Formati HDR
- Dolby Vision Profilo 5
- Dolby Vision Profilo 7
- Dolby Vision Profilo 8
- Forza abilitazione
- Forza disabilitazione
- La versione del server non sarà più supportata a breve
- Versione del server richiesta: %1$s
- Serie
- Errore
- Utente
- Carica predefinito dal server
- Salva solo su questo dispositivo
- Predefinito
- Specifico del dispositivo
- Profilo utente del server
- Predefinito del server
- Client web utente
- Gestito dal server
- Scelto da %1$s
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index d05db022..fe4ce431 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -220,7 +220,7 @@
- בגיל שנתיים
- בגיל %1$d
- נגן עם המרת־קידוד
+ נגן עם המרת קידוד
פרטי המדיה
הצג שעון
סדר שידור הפרקים
@@ -613,7 +613,7 @@
התנהגות דילוג
עבור פתיחים, כתוביות סיום וכו\'
נגן עם
- המרת־קידוד
+ המרת קידוד
NAL
AVC
SDR
@@ -738,22 +738,4 @@
עונה %1$02d פרקים %2$02d-%3$02d
פרק %1$d
עונה %1$d
- העדפת Dolby Vision על פני HDR10+
- מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה
- מחיקה מרשימת־השמעה
- פרטי ההתחברות פגו, נא להתחבר מחדש
- אינך מורשה לגשת לשרת בשלב זה
- רזולוציה מירבית לפני המרת־קידוד
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - פרק %1$d
- - %1$d פרקים
- - %1$d פרקים
-
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
deleted file mode 100644
index 045e125f..00000000
--- a/app/src/main/res/values-ja/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 3bb8567e..83247c34 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -763,46 +763,4 @@
S%1$02d O%2$02d-%3$02d
Wyłącz sprzętowe wsparcie dźwięku
Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach
- 8K
- 4K
- 1080p
- 720p
- 576p
- 480p
- 360p
-
- - %1$d odcinek
- - %1$d odcinki
- - %1$d odcinków
- - %1$d odcinków
-
- Maksymalna rozdzielczość przed transkodowaniem
- Preferuj Dolby Vision nad HDR10+
- Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+
- Najlepiej oceniane
- Formaty HDR
- HDR10
- HDR10+
- Dolby Vision P5
- Dolby Vision P7
- Dolby Vision P8
- Wymuś włączenie
- Wymuś wyłączenie
- Wersja serwera wkrótce przestanie być wspierana
- Wymagana wersja serwera: %1$s
- Wybrane przez %1$s
- Użytkownik
- Seriale
- Załaduj domyślne z serwera
- Zapisuj tylko na tym urządzeniu
- Domyślnie
- Zależne od urządzenia
- Profil użytkownika na serwerze
- Domyślnie z serwera
- Użytkownik klienta webowego
- Konwertuj Dolby Vision 7 do 8.1
- W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie
- Źródło artysty
- Zarządzane przez serwer
- Błąd
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index f65e53af..d65ac309 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -741,44 +741,4 @@
Remover da lista de reprodução
As credenciais expiraram. Inicie sessão novamente
Não tem autorização para aceder ao servidor neste momento
- Mais votados
- Formato HDR
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Ativação forçada
- Desativação forçada
- Preferir Dolby Vision sobre HDR10+
- Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+
- Converte Dolby Vision Profile 7 para 8.1
- Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7
- A versão do servidor deixará de ser suportada em breve
- Versão do servidor necessária: %1$s
- Resolução máxima antes da transcodificação
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Episódio
- - %1$d Episódios
- - %1$d Episódios
-
- Escolhido por %1$s
- Utilizador
- Séries
- Carregar as predefinições do servidor
- Guardar apenas neste dispositivo
- Padrão
- Específico do dispositivo
- Perfil de utilizador do servidor
- Padrão do servidor
- Utilizador do cliente web
- Gerido pelo servidor
- Erro
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index ca59ccb6..f7cc6e4c 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -763,43 +763,4 @@
Убрать из плейлиста
Срок действия учётных данных истёк. Пожалуйста, войдите снова
У вас нет доступа к серверу в данный момент
- С высоким рейтингом
- Форматы HDR
- HDR10
- HDR10+
- Dolby Vision, профиль 5
- Dolby Vision, профиль 7
- Dolby Vision, профиль 8
- Принудительно включить
- Принудительно отключить
- Приоритет Dolby Vision над HDR10+
- Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+
- Поддержка этой версии сервера скоро прекратится
- Требуемая версия сервера: %1$s
- Максимальное разрешение без перекодирования
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d эпизод
- - %1$d эпизода
- - %1$d эпизодов
- - %1$d эпизодов
-
- Источник выбора: %1$s
- Пользователь
- Сериал
- Загрузить настройки по умолчанию с сервера
- Сохранить только на этом устройстве
- По умолчанию
- Настройки этого устройства
- Профиль пользователя на сервере
- Настройки по умолчанию на сервере
- Веб-клиент пользователя
- Управляется сервером
- Ошибка
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index 50d2edd7..bc78a1f8 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -763,20 +763,4 @@
Odstrániť z playlistu
Platnosť prihlasovacích údajov vypršala, prihláste sa znova
Momentálne nemáte oprávnenie na prístup k serveru
- Preferovať Dolby Vision pred HDR10+
- Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision
- Maximálne rozlíšenie pred prekódovaním
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d epizóda
- - %1$d epizódy
- - %1$d epizód
- - %1$d epizód
-
diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml
index 4244a0df..3df78be2 100644
--- a/app/src/main/res/values-sr/strings.xml
+++ b/app/src/main/res/values-sr/strings.xml
@@ -1,134 +1,4 @@
O aplikaciji
- Aktivni Snimci
- Omiljeni
- Dodaj Server
- Dodaj Korisnika
- Audio
- Mesto rođenja
- Bitrate
- Rođen
- Prekini Snimanje
- Prekini Snimanje Serije
- Prekini
- Poglavlja
- Izaberi
- Očisti keš slika
- Kolekcija
- Kolekcije
- Reklama
- Ocena Zajednice
- Greška! Pritisni dugme da pošalješ logove serveru.
- Potvrdi
- Nastavi sa gledanjem
- Ocena Kritičara
- %.2f sekunde
- %1$dh %2$dm %3$ds
- %1$dh %2$dm
- %1$dh %2$ds
- %1$dm %2$ds
- %1$dh
- %1$dm
- %1$ds
- Podrazumevano
- Obriši
- Umro
- Režirao %1$s
- Režiser
- Isključeno
- Otkriveni Serveri
-
- Preuzimanje…
- Uključeno
- Završava se u %1$s
- Upiši Server IP ili URL
- Upiši server adresu
- Epizode
- Greška pri učitavanju kolekcije %1$s
- Eksterni
- Omiljeni
- Veličina
- Forsiran
- Žanrovi
- Uđi u serije
- Idi do
- Sakrij kontrole za reprodukciju
- Sakrij debug informacije
- Sakrij
- Kuća
- Odmah
- Intro
- #ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ
- Biblioteka
- Informacije o licenci
- TV Uživo
- Učitavanje…
- Obeleži celu seriju kao odgledanu?
- Obeleži celu seriju kao neodgledanu?
- Obeleži kao neodgledano
- Obeleži kao odgledano
- Maks bitrate
- Slično ovome
- Još
- Filmovi
- Ime
- Sledeće
- Nema podataka
- Nema rezultata
- Server nije pronađen
- Nema zakazanih snimaka
- Nema dostupnih ažuriranja
- Nijedan
- Samo Forsirani Titlovi
- Outro
- Putanja
- Ljudi
- Broj Prikaza
- Pusti odavde
- Pusti
- Prikaži debug info reprodukcije
- Brzina Reprodukcije
- Reprodukcija
- Plejlista
- Plejliste
- Pregled
- Korisnička Podešavanja
- Korisnička podešavanja sa servera
- Red
- Rekapitulacija
- Nedavno dodato u %1$s
- Nedavno dodato
- Nedavno Snimljeno
- Nedavno Objavljeno
- Preporučeno
- Snimi Program
- Snimi Seriju
- Ukloni iz omiljenih
- Restart
- Nastavi
- Sačuvaj
-
- Pretraži
- Pretraga…
- Glasovna pretraga
- Započinje
- Pretraži govorom
- Obrađuje se
- Pritisni nazad za prekid
- Greška u audio zapisu
- Greška u prepoznavanju glasa
- Potrebna je dozvola za mikrofon
- Greška u mreži
- Mreža izgubljena
- Nije prepoznat govor
- Prepoznavanje govora zauzeto
- Greška u Serveru
- Nije detektovan govor
- Nepoznata greška
- Greška u pokretanju prepoznavanja glasa
- Greška u prepoznavanju glasa
- Pokušaj ponovo
- Odaberi Server
- Odaberi Korisnika
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 002d5ece..3b71517a 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -686,9 +686,4 @@
Användarvisningar
%1$sfilmer
Kvalitetsprofil
- HDR-format
- HDR10
- HDR10+
- Tvinga aktivering
- Tvinga avaktivering
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 70804da5..f591c3fb 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -13,7 +13,7 @@
Dizi kaydını iptal et
İptal
Bölümler
- %1$s ögesini seçin
+ Seç: %1$s
Resim önbelleğini temizle
Koleksiyon
Koleksiyonlar
@@ -718,45 +718,4 @@
Oynatma listesinden kaldır
Oturum süresi doldu, lütfen tekrar giriş yapın
Şu anda sunucuya erişim yetkiniz bulunmuyor
- HDR10+ yerine Dolby Vision\'ı tercih et
- Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır
- Dönüştürme öncesi maksimum çözünürlük
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
-
- - %1$d Bölüm
- - %1$d Bölüm
-
- Yer Aldığı
- En Yüksek Puanlı
- HDR Formatları
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Zorla Etkinleştir
- Zorla Devre Dışı Bırak
- Sunucu sürümü yakında desteklenmeyecek
- Gerekli sunucu sürümü: %1$s
- Seçen: %1$s
- Kullanıcı
- Diziler
- Sunucudan varsayılanları yükle
- Yalnızca bu cihaza kaydet
- Varsayılan
- Cihaza özel
- Sunucu kullanıcı profili
- Sunucu varsayılanı
- Kullanıcı web istemcisi
- Sunucu tarafından yönetiliyor
- Hata
- Dolby Vision Profile 7\'yi 8.1\'e Dönüştür
- Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır
- Sanatçı Kaynağı
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index c82ff0d8..7c10daab 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -697,36 +697,4 @@
從播放清單中移除
您目前無存取此伺服器的權限
登入憑證已過期,請重新登入
-
- - %1$d 集
-
- Dolby Vision 優先於 HDR10+
- 移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+
- 最高解析度,超過將轉碼
- 8K
- 4K
- 1080
- 720
- 576
- 480
- 360
- HDR10
- HDR10+
- 杜比視界 Profile 5
- 杜比視界 Profile 7
- 杜比視界 Profile 8
- 強制啟用
- 強制停用
- 伺服器版本即將停止支援
- 伺服器版本需為:%1$s
- 使用者
- 劇集
- 從伺服器載入預設值
- 僅儲存於此裝置
- 預設
- 高評分
- HDR 格式
- 由 %1$s 精選
- 將杜比視界 Profile 7 轉換為 8.1
- 在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3783bfdf..84ad72c7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -154,7 +154,6 @@
Switch
%1$s left
Top Rated Unwatched
- Top Rated
Trailers
DVR Schedule
Guide
@@ -175,7 +174,7 @@
Video Scale
Video
Videos
- Watch live
+ Watch liveg
- %1$d years old
- %1$d year old
@@ -415,19 +414,6 @@
Image disk cache size (MB)
Disable audio hardware offload
Disabling offloading audio decoding may fix playback freezes on some devices
- HDR formats
- HDR10
- HDR10+
- Dolby Vision Profile 5
- Dolby Vision Profile 7
- Dolby Vision Profile 8
- Force enable
- Force disable
-
- - @string/automatic
- - @string/force_enable
- - @string/force_disable
-
View options
Columns
Spacing
@@ -504,8 +490,6 @@
Ignores device compatibility checks
Prefer Dolby Vision over HDR10+
Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+
- Convert Dolby Vision Profile 7 to 8.1
- Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7
Discover
Request
@@ -808,11 +792,6 @@
Creator
Artist
Album artist
- Artist source
-
- - @string/artist
- - @string/album_artist
-
Album
Popular songs
Play next
@@ -941,8 +920,6 @@
Books
Audio books
Server version not supported
- Server version will not be supported soon
- Required server version: %1$s
Appears on
Dim screen after delay
Dim percentage
@@ -976,16 +953,4 @@
- %1$d Episode
- %1$d Episodes
- Chosen by %1$s
- User
- Series
- Load default from server
- Save only to this device
- Default
- Device specific
- Server user profile
- Server default
- User web client
- Managed by server
- Error
diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt
deleted file mode 100644
index 11006322..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.github.damontecres.wholphin.data.model
-
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Test
-import java.util.UUID
-
-class BaseItemSubtitleTest {
- private fun album(
- albumArtist: String? = null,
- artists: List? = null,
- productionYear: Int? = null,
- ) = BaseItem(
- BaseItemDto(
- id = UUID.randomUUID(),
- type = BaseItemKind.MUSIC_ALBUM,
- name = "Test Album",
- albumArtist = albumArtist,
- artists = artists,
- productionYear = productionYear,
- ),
- )
-
- @Test
- fun albumPrefersAlbumArtist() {
- val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021)
- assertEquals("Album Artist - 2021", item.subtitle)
- }
-
- @Test
- fun albumFallsBackToArtists() {
- val item = album(artists = listOf("A", "B"), productionYear = 2021)
- assertEquals("A, B - 2021", item.subtitle)
- }
-
- @Test
- fun albumWithoutYear() {
- assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle)
- }
-
- @Test
- fun albumWithoutArtist() {
- assertEquals("2021", album(productionYear = 2021).subtitle)
- assertNull(album(artists = emptyList()).subtitle)
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt
deleted file mode 100644
index 283ee8ca..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.github.damontecres.wholphin.data.model
-
-import com.github.damontecres.wholphin.test.TestTracks
-import com.github.damontecres.wholphin.test.assertNotNull
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class RelativeMediaStreamTest {
- @Test
- fun `Test getRelativeIndex`() {
- val source =
- TestTracks
- .Builder()
- .addSubtitle(2)
- .addAudio(2)
- .addVideo()
- .buildMediaSourceInfo()
- val streams = source.mediaStreams!!
- RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex ->
- assertNotNull(relativeIndex)
- assertEquals(0, relativeIndex)
- }
- RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex ->
- assertNotNull(relativeIndex)
- assertEquals(1, relativeIndex)
- }
- RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex ->
- assertNotNull(relativeIndex)
- assertEquals(0, relativeIndex)
- }
- RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex ->
- assertNotNull(relativeIndex)
- assertEquals(1, relativeIndex)
- }
- }
-
- @Test
- fun `Test getRelativeIndex for stream`() {
- val source =
- TestTracks
- .Builder()
- .addSubtitle(2)
- .addAudio(2)
- .addVideo()
- .buildMediaSourceInfo()
- val streams = source.mediaStreams!!
- RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex ->
- assertNotNull(relativeIndex)
- assertEquals(1, relativeIndex)
- }
- }
-
- @Test
- fun `Test asRelative basic`() {
- val source =
- TestTracks
- .Builder()
- .addSubtitle(2)
- .addAudio(2)
- .addVideo()
- .buildMediaSourceInfo()
- val streams = source.mediaStreams!!
- assertEquals(5, streams.size)
-
- val relative = streams.asRelative()
- // Subtitle
- assertEquals(0, relative[0].relativeIndex)
- assertEquals(0, relative[0].stream.index)
- assertEquals(1, relative[1].relativeIndex)
- assertEquals(1, relative[1].stream.index)
-
- // Audio
- assertEquals(0, relative[2].relativeIndex)
- assertEquals(2, relative[2].stream.index)
- assertEquals(1, relative[3].relativeIndex)
- assertEquals(3, relative[3].stream.index)
-
- // Video
- assertEquals(0, relative[4].relativeIndex)
- assertEquals(4, relative[4].stream.index)
- }
-
- @Test
- fun `Test asRelative with external`() {
- val source =
- TestTracks
- .Builder()
- .addExternalSubtitle(2)
- .addSubtitle(2)
- .addAudio(2)
- .buildMediaSourceInfo()
- val streams = source.mediaStreams!!
- assertEquals(6, streams.size)
-
- val relative = streams.asRelative()
- // External Subtitle
- assertEquals(0, relative[0].relativeIndex)
- assertEquals(0, relative[0].stream.index)
- assertEquals(1, relative[1].relativeIndex)
- assertEquals(1, relative[1].stream.index)
-
- // Subtitle
- assertEquals(0, relative[2].relativeIndex)
- assertEquals(2, relative[2].stream.index)
- assertEquals(1, relative[3].relativeIndex)
- assertEquals(3, relative[3].stream.index)
-
- // Audio
- assertEquals(0, relative[4].relativeIndex)
- assertEquals(4, relative[4].stream.index)
- assertEquals(1, relative[5].relativeIndex)
- assertEquals(5, relative[5].stream.index)
- }
-
- @Test
- fun `Test asRelative with external mixed`() {
- val source =
- TestTracks
- .Builder()
- .addExternalSubtitle(1)
- .addSubtitle(1)
- .addAudio(1)
- .addExternalSubtitle(1)
- .addAudio(1)
- .addSubtitle(1)
- .buildMediaSourceInfo()
- val streams = source.mediaStreams!!
- assertEquals(6, streams.size)
-
- val relative = streams.asRelative()
- // External Subtitle
- assertEquals(0, relative[0].relativeIndex)
- assertEquals(0, relative[0].stream.index)
- assertEquals(1, relative[3].relativeIndex)
- assertEquals(3, relative[3].stream.index)
-
- // Subtitle
- assertEquals(0, relative[1].relativeIndex)
- assertEquals(1, relative[1].stream.index)
- assertEquals(1, relative[5].relativeIndex)
- assertEquals(5, relative[5].stream.index)
-
- // Audio
- assertEquals(0, relative[2].relativeIndex)
- assertEquals(2, relative[2].stream.index)
- assertEquals(1, relative[4].relativeIndex)
- assertEquals(4, relative[4].stream.index)
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt
deleted file mode 100644
index d179c7e1..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt
+++ /dev/null
@@ -1,207 +0,0 @@
-package com.github.damontecres.wholphin.services
-
-import com.github.damontecres.wholphin.data.model.BaseItem
-import io.mockk.every
-import io.mockk.mockk
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.imageApi
-import org.jellyfin.sdk.api.operations.ImageApi
-import org.jellyfin.sdk.model.UUID
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ImageType
-import org.jellyfin.sdk.model.serializer.toUUID
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Test
-
-class ImageUrlServiceTest {
- private val mockApiClient = mockk()
- private val imageUrlService = ImageUrlService(mockApiClient)
-
- private val itemId = UUID.randomUUID()
- private val seriesId = UUID.randomUUID()
- private val seasonId = UUID.randomUUID()
-
- @Before
- fun setup() {
- every { mockApiClient.baseUrl } returns "http://localhost"
- every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() }
- every { mockApiClient.imageApi } returns ImageApi(mockApiClient)
- }
-
- private fun episode(
- imageTags: Map = emptyMap(),
- parentLogoItemId: UUID? = null,
- parentThumbItemId: UUID? = null,
- parentBackdropItemId: UUID? = null,
- ) = BaseItemDto(
- id = itemId,
- type = BaseItemKind.EPISODE,
- seriesId = seriesId,
- seasonId = seasonId,
- imageTags = imageTags,
- seriesPrimaryImageTag = "seriesPrimary",
- parentLogoItemId = parentLogoItemId,
- parentLogoImageTag = parentLogoItemId?.let { "parentLogo" },
- parentThumbItemId = parentThumbItemId,
- parentThumbImageTag = parentThumbItemId?.let { "parentThumb" },
- parentBackdropItemId = parentBackdropItemId,
- parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") },
- )
-
- private fun movie(
- imageTags: Map = emptyMap(),
- backdropImageTags: List? = null,
- ) = BaseItemDto(
- id = itemId,
- type = BaseItemKind.MOVIE,
- imageTags = imageTags,
- backdropImageTags = backdropImageTags,
- )
-
- private fun assertImage(
- url: String?,
- expectedId: UUID,
- expectedType: ImageType,
- expectedTag: String?,
- ) {
- Assert.assertNotNull(url)
- val path = url!!.substringBefore('?')
- Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID())
- Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/"))
- val tag =
- url
- .substringAfter('?', "")
- .split('&')
- .firstOrNull { it.startsWith("tag=") }
- ?.substringAfter("tag=")
- Assert.assertEquals(expectedTag, tag)
- }
-
- @Test
- fun `Own images use own tags`() {
- val item =
- BaseItem(
- movie(
- imageTags =
- mapOf(
- ImageType.PRIMARY to "primary",
- ImageType.LOGO to "logo",
- ImageType.THUMB to "thumb",
- ImageType.BANNER to "banner",
- ImageType.ART to "art",
- ),
- backdropImageTags = listOf("backdrop0", "backdrop1"),
- ),
- )
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0")
- }
-
- @Test
- fun `Missing tags are omitted`() {
- val item = BaseItem(movie())
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null)
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null)
- Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP))
- }
-
- @Test
- fun `Thumb falls back to own backdrop`() {
- val item = BaseItem(movie(backdropImageTags = listOf("backdrop0")))
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0")
- }
-
- @Test
- fun `Episode primary uses series tag`() {
- val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true)
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null)
- assertImage(
- imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false),
- itemId,
- ImageType.PRIMARY,
- "primary",
- )
- }
-
- @Test
- fun `Season without primary uses series tag`() {
- val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null))
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
- }
-
- @Test
- fun `Episode logo uses parent logo tag only if from series`() {
- assertImage(
- imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO),
- seriesId,
- ImageType.LOGO,
- "parentLogo",
- )
- assertImage(
- imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO),
- seriesId,
- ImageType.LOGO,
- null,
- )
- }
-
- @Test
- fun `Episode backdrop uses parent backdrop tag only if from series`() {
- assertImage(
- imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP),
- seriesId,
- ImageType.BACKDROP,
- "parentBackdrop0",
- )
- assertImage(
- imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP),
- seriesId,
- ImageType.BACKDROP,
- null,
- )
- }
-
- @Test
- fun `Episode thumb uses parent tags`() {
- assertImage(
- imageUrlService.getItemImageUrl(
- BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true),
- ImageType.THUMB,
- ),
- seriesId,
- ImageType.THUMB,
- "parentThumb",
- )
- assertImage(
- imageUrlService.getItemImageUrl(
- BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true),
- ImageType.THUMB,
- ),
- seriesId,
- ImageType.BACKDROP,
- "parentBackdrop0",
- )
- assertImage(
- imageUrlService.getItemImageUrl(
- BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true),
- ImageType.THUMB,
- ),
- itemId,
- ImageType.PRIMARY,
- "primary",
- )
- }
-
- @Test
- fun `Season without thumb uses parent thumb tag`() {
- val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON))
- assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb")
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
index 2bcc6ab7..ee32c7dd 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
@@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser
-import com.github.damontecres.wholphin.data.RestoredSession
+import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
@@ -20,6 +20,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
+import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
@@ -41,6 +42,7 @@ import org.robolectric.annotation.Config
class IntentServiceTest {
private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk()
+ private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
@@ -62,6 +64,7 @@ class IntentServiceTest {
intentService = IntentService(api, serverRepository, userPreferencesService)
every { api.userLibraryApi } returns userLibraryApi
coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId))
+ every { serverRepository.serverDao } returns serverDao
}
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
@@ -79,11 +82,11 @@ class IntentServiceTest {
private fun setupAutoSignInUnprotected() {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.Success(currentUser)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
}
@Test
@@ -95,7 +98,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
- coVerify { serverRepository.restoreLastSession() }
+ coVerify { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -103,17 +106,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = false
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.Success(currentUser)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -121,17 +124,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.ServerOnly(protectedCurrentUser.server)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -139,17 +142,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.ServerOnly(protectedCurrentUser.server)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 1) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -157,16 +160,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = ""
+ currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -174,11 +178,12 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = ""
+ currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.Success(currentUser)
+ coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
val intent =
Intent().apply {
@@ -188,7 +193,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
- coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -196,11 +201,12 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = ""
+ currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
-// coEvery { serverRepository.restoreLastSession() } returns
-// RestoredSession.ServerOnly(protectedCurrentUser.server)
+ coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent =
Intent().apply {
@@ -210,7 +216,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -218,9 +224,12 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = ""
+ currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
+ coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns null
val intent =
Intent().apply {
@@ -230,7 +239,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
}
@Test
@@ -287,11 +296,12 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery {
- serverRepository.tryChangeUser(
+ serverRepository.restoreSession(
newServerId,
newUserId,
)
} returns newCurrentUser
+ every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@@ -306,8 +316,9 @@ class IntentServiceTest {
assertEquals(0, destinations.size)
assertTrue(result.addHomeToBackStack)
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
- coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ verify { serverDao.getUser(newServerId, newUserId) }
+ coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
}
@@ -322,11 +333,12 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery {
- serverRepository.tryChangeUser(
+ serverRepository.restoreSession(
newServerId,
newUserId,
)
} returns newCurrentUser
+ every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@@ -346,8 +358,9 @@ class IntentServiceTest {
assertEquals(itemId, first.itemId)
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
- coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ verify { serverDao.getUser(newServerId, newUserId) }
+ coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify { userLibraryApi.getItem(itemId) }
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
index 2c2fdffe..499208d6 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
@@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.services
import android.content.Context
+import android.content.SharedPreferences
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
-import com.github.damontecres.wholphin.data.MostRecentServer
-import com.github.damontecres.wholphin.data.MostRecentServerProvider
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
@@ -16,7 +14,6 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.test.nonBlankString
-import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
@@ -24,7 +21,6 @@ import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.Runs
import io.mockk.coEvery
-import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -33,6 +29,7 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -51,8 +48,6 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.After
import org.junit.Assert
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -85,13 +80,14 @@ class ServerRepositoryTest {
private val mockUserApi = mockk()
private val mockSystemApi = mockk()
- private val mockMostRecentServerProvider = mockk()
+ private val mockSharedPreferences = mockk(relaxed = true)
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
WholphinDispatchers.configure(testDispatcher)
+ every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences
every { mockApiClient.userApi } returns mockUserApi
every { mockApiClient.systemApi } returns mockSystemApi
coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto)
@@ -106,15 +102,9 @@ class ServerRepositoryTest {
emptyMap(),
)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
- coEvery { mockJellyfinServerDao.getServer(serverId) } returns
- JellyfinServerUsers(server, listOf(user))
- coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs
every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1")
every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device")
every { mockApiClient.update(any(), any(), any(), any()) } just Runs
- coEvery { mockMostRecentServerProvider.save(any()) } just Runs
- coEvery { mockMostRecentServerProvider.clear() } just Runs
- coEvery { mockMostRecentServerProvider.clearUser() } just Runs
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -129,8 +119,8 @@ class ServerRepositoryTest {
mockJellyfin,
mockJellyfinServerDao,
mockApiClient,
+ dataStore,
testDispatcher,
- mockMostRecentServerProvider,
)
private val serverId = UUID.randomUUID()
@@ -204,24 +194,24 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
+ val appPreferences = dataStore.data.first()
+ Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
+ Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
+
+ verify(exactly = 1) { mockSharedPreferences.edit() }
verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) }
}
@Test
fun `Test restoreUser via changeUser`() =
runTest {
- val user = user.copy(pin = null)
every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs
every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
Assert.assertNull(serverRepository.currentUser)
- val result = serverRepository.tryChangeUser(server.id, user.id)
- assertTrue(result != null)
- assertEquals(serverId, result?.server?.id)
- assertEquals(user.id, result?.user?.id)
+ serverRepository.restoreSession(server.id, user.id)
verify(exactly = 1) {
mockApiClient.update(
@@ -236,6 +226,34 @@ class ServerRepositoryTest {
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
}
+ @Test
+ fun `Test restoreUser via shortcut`() =
+ runTest {
+ every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
+
+ val serverRepository = create()
+ setUpCurrentUser(serverRepository)
+
+ serverRepository.restoreSession(server.id, user.id)
+
+ verify(exactly = 1) {
+ mockApiClient.update(
+ baseUrl = server.url,
+ accessToken = user.accessToken,
+ clientInfo = any(),
+ deviceInfo = any(),
+ )
+ }
+ verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) }
+ verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) }
+
+ Assert.assertEquals(server, serverRepository.currentServer)
+ Assert.assertEquals(user, serverRepository.currentUser)
+
+ // TODO fix this, need to check userDto too
+// Assert.assertEquals(userDto, serverRepository.currentUserDto)
+ }
+
@Test
fun `Test remove user`() =
runTest {
@@ -290,7 +308,6 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) }
- coVerify { mockMostRecentServerProvider.clearUser() }
}
@Test
@@ -334,7 +351,6 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) }
- coVerify { mockMostRecentServerProvider.clear() }
}
@Test
@@ -371,7 +387,11 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
+ val appPreferences = dataStore.data.first()
+ Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
+ Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
+
+ verify(exactly = 1) { mockSharedPreferences.edit() }
}
@Test
@@ -410,7 +430,11 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
+ val appPreferences = dataStore.data.first()
+ Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
+ Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
+
+ verify(exactly = 1) { mockSharedPreferences.edit() }
}
@Test
@@ -460,99 +484,4 @@ class ServerRepositoryTest {
}
}
}
-
- @Test
- fun `Test restoreLastSession, success`() =
- runTest {
- val user = user.copy(pin = null)
- coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
- coEvery { mockJellyfinServerDao.getServer(serverId) } returns
- JellyfinServerUsers(server, listOf(user))
- every { mockMostRecentServerProvider.get() } returns
- MostRecentServer.from(CurrentUser(server, user))
-
- val result = create().restoreLastSession()
- assertTrue(result is RestoredSession.Success)
- val current = (result as RestoredSession.Success).currentUser
- assertEquals(server, current.server)
- assertEquals(user, current.user)
-
- verify(exactly = 1) {
- mockApiClient.update(
- baseUrl = server.url,
- accessToken = user.accessToken,
- clientInfo = any(),
- deviceInfo = any(),
- )
- }
- coVerify { mockMostRecentServerProvider.save(any()) }
- }
-
- @Test
- fun `Test restoreLastSession, user protected`() =
- runTest {
- every { mockMostRecentServerProvider.get() } returns
- MostRecentServer.ServerAndUser(serverId, server.url, userId, "token")
-
- val result = create().restoreLastSession()
- assertTrue(result is RestoredSession.ServerOnly)
- val current = (result as RestoredSession.ServerOnly)
- assertEquals(server, current.server)
- }
-
- @Test
- fun `Test restoreLastSession, server only`() =
- runTest {
- every { mockMostRecentServerProvider.get() } returns
- MostRecentServer.Server(server.id, server.url)
-
- val result = create().restoreLastSession()
- assertTrue(result is RestoredSession.ServerOnly)
- val current = (result as RestoredSession.ServerOnly)
- assertEquals(server, current.server)
- }
-
- @Test
- fun `Test restoreLastSession, nothing to restore`() =
- runTest {
- every { mockMostRecentServerProvider.get() } returns MostRecentServer.None
-
- val result = create().restoreLastSession()
- assertTrue(result is RestoredSession.None)
- }
-}
-
-class TestMostRecentServerProvider : MostRecentServerProvider {
- var mostRecentServer: MostRecentServer = MostRecentServer.None
-
- override fun get(): MostRecentServer = mostRecentServer
-
- override suspend fun save(current: CurrentUser) {
- mostRecentServer =
- if (current.user.accessToken.isNotNullOrBlank()) {
- MostRecentServer.ServerAndUser(
- current.server.id,
- current.server.url,
- current.user.id,
- current.user.accessToken,
- )
- } else {
- MostRecentServer.Server(
- current.server.id,
- current.server.url,
- )
- }
- }
-
- override suspend fun clear() {
- mostRecentServer = MostRecentServer.None
- }
-
- override suspend fun clearUser() {
- when (val s = mostRecentServer) {
- MostRecentServer.None -> Unit
- is MostRecentServer.Server -> Unit
- is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl)
- }
- }
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
index d8304897..b300e09a 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
@@ -1,12 +1,10 @@
package com.github.damontecres.wholphin.services
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
+import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -525,54 +523,6 @@ class TestStreamChoiceServiceOnlyForced(
),
streamAudioLang = "eng",
),
- TestInput(
- 0,
- SubtitlePlaybackMode.NONE,
- userSubtitleLang = null,
- subtitles =
- listOf(
- subtitle(0, "eng", forced = true),
- subtitle(1, "spa", forced = false),
- ),
- streamAudioLang = "eng",
- plc = plc(subtitlesOnlyForced = true),
- ),
- TestInput(
- 0,
- SubtitlePlaybackMode.DEFAULT,
- userSubtitleLang = null,
- subtitles =
- listOf(
- subtitle(0, "eng", forced = true),
- subtitle(1, "spa", forced = true),
- ),
- streamAudioLang = "eng",
- plc = plc(subtitlesOnlyForced = true),
- ),
- TestInput(
- 0,
- SubtitlePlaybackMode.DEFAULT,
- userSubtitleLang = "eng",
- subtitles =
- listOf(
- subtitle(0, "eng", forced = true),
- subtitle(1, "spa", forced = false),
- ),
- streamAudioLang = "eng",
- plc = plc(subtitlesOnlyForced = true),
- ),
- TestInput(
- null,
- SubtitlePlaybackMode.NONE,
- userSubtitleLang = null,
- subtitles =
- listOf(
- subtitle(0, "eng", forced = false),
- subtitle(1, "spa", forced = false),
- ),
- streamAudioLang = "eng",
- plc = plc(subtitlesOnlyForced = true),
- ),
)
}
}
@@ -935,7 +885,7 @@ data class TestInput(
val streamAudioLang: String? = "eng",
val subtitles: List,
val itemPlayback: ItemPlayback? = null,
- val plc: List = emptyList(),
+ val plc: PlaybackLanguageChoice? = null,
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
@@ -945,7 +895,7 @@ data class TestInput(
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
-fun mockServerRepo(
+private fun serverRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
@@ -968,15 +918,15 @@ fun mockServerRepo(
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
- mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
- mockk(),
+ serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
+ mockk(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
- stc = input.plc,
+ playbackLanguageChoice = input.plc,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
@@ -987,7 +937,7 @@ private fun runTest(input: TestInput) {
),
),
)
- Assert.assertEquals(input.expectedIndex, result.stream?.index)
+ Assert.assertEquals(input.expectedIndex, result?.index)
}
fun subtitle(
@@ -995,8 +945,6 @@ fun subtitle(
lang: String?,
default: Boolean = false,
forced: Boolean = false,
- sdh: Boolean = false,
- external: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
@@ -1004,26 +952,6 @@ fun subtitle(
language = lang,
isDefault = default,
isForced = forced,
- isHearingImpaired = sdh,
- isInterlaced = false,
- index = index,
- isExternal = external,
- isTextSubtitleStream = true,
- supportsExternalStream = true,
- title = title,
- )
-
-fun audio(
- index: Int,
- lang: String?,
- default: Boolean = false,
- title: String? = null,
-): MediaStream =
- MediaStream(
- type = MediaStreamType.AUDIO,
- language = lang,
- isDefault = default,
- isForced = false,
isHearingImpaired = false,
isInterlaced = false,
index = index,
@@ -1050,63 +978,11 @@ private fun plc(
audioLang: String? = null,
subtitleLang: String? = null,
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
- subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
-): List =
- buildList {
- audioLang
- ?.let {
- SeriesTrackChoice(
- userId = 1,
- language = audioLang,
- parentId = UUID.randomUUID(),
- type = SeriesTrackChoiceType.AUDIO,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- )
- }?.let(::add)
-
- if (subtitleLang != null) {
- SeriesTrackChoice(
- userId = 1,
- language = subtitleLang,
- parentId = UUID.randomUUID(),
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- ).let(::add)
- } else if (subtitlesDisabled == true) {
- SeriesTrackChoice(
- userId = 1,
- language = null,
- parentId = UUID.randomUUID(),
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- ).let(::add)
- } else if (subtitlesOnlyForced == true) {
- SeriesTrackChoice(
- userId = 1,
- language = null,
- parentId = UUID.randomUUID(),
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ONLY_FORCED,
- trackFlags = 0,
- codec = null,
- relativeTrackIndex = null,
- title = null,
- audioChannels = null,
- ).let(::add)
- }
- }
+): PlaybackLanguageChoice =
+ PlaybackLanguageChoice(
+ userId = 1,
+ seriesId = UUID.randomUUID(),
+ audioLanguage = audioLang,
+ subtitleLanguage = subtitleLang,
+ subtitlesDisabled = subtitlesDisabled,
+ )
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
index 0e5e0eba..e066e837 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
@@ -5,10 +5,8 @@ import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.CurrentUser
-import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import io.mockk.coEvery
import io.mockk.coVerify
@@ -115,16 +113,16 @@ class SuggestionsWorkerTest {
val mockUser = mockk()
var restored = false
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
- coEvery { mockServerRepository.restoreLastSession() } coAnswers {
+ coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
restored = true
- RestoredSession.Success(currentUser())
+ mockUser
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
val result = createWorker().doWork()
- coVerify { mockServerRepository.restoreLastSession() }
+ coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
assertEquals(ListenableWorker.Result.success(), result)
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt
deleted file mode 100644
index e38479f0..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.github.damontecres.wholphin.test
-
-import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.preferences.HdrOverrideMode
-import com.github.damontecres.wholphin.preferences.PlaybackOverrides
-import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
-import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
-import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
-import org.junit.Assert
-import org.junit.Test
-
-class TestHdrFormat {
- private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences =
- AppPreferences.getDefaultInstance().updatePlaybackOverrides(block)
-
- @Test
- fun `Test all auto returns empty sets`() {
- val prefs = appPreferences { }
- Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true))
- Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
- }
-
- @Test
- fun `Test HDR10 override`() {
- val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.HDR10.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
- )
- Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
- }
-
- @Test
- fun `Test Dolby Vision Profile 5 override`() {
- val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.DOVI.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
- )
- }
-
- @Test
- fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() {
- val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
- Assert.assertEquals(
- emptySet(),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false),
- )
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
- )
- }
-
- @Test
- fun `Test Dolby Vision Profile 8 override`() {
- val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false),
- )
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
- )
- }
-
- @Test
- fun `Test multiple formats set to the same mode are unioned`() {
- val prefs =
- appPreferences {
- hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
- hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE
- doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE
- }
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
- )
- Assert.assertEquals(
- setOf(WholphinVideoRangeType.DOVI.serialName),
- prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
- )
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
index a6fe6e2a..e8c2fed1 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
@@ -1,9 +1,7 @@
package com.github.damontecres.wholphin.test
-import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
-import com.github.damontecres.wholphin.data.model.KeyValueEntry
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.ui.AspectRatio
@@ -15,6 +13,7 @@ import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.Assert
import org.junit.Test
import kotlin.reflect.KClass
@@ -62,8 +61,8 @@ class TestHomeRowSamples {
),
HomeRowConfig.GetItems(
name = "Episodes by date created",
- filter =
- HomeGetItemsFilter(
+ getItems =
+ GetItemsRequest(
parentId = UUID.randomUUID(),
recursive = true,
isFavorite = true,
@@ -78,12 +77,6 @@ class TestHomeRowSamples {
HomeRowConfig.TvPrograms(),
HomeRowConfig.TvChannels(),
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
- HomeRowConfig.CustomEndpoint(
- endpoint = "/path/to/items",
- title = "Items",
- query = listOf(KeyValueEntry("key", "value")),
- headers = listOf(KeyValueEntry("X-Header", "1234")),
- ),
)
}
@@ -107,7 +100,6 @@ class TestHomeRowSamples {
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
is HomeRowConfig.Studios -> foundTypes.add(it::class)
- is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class)
}
}
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
@@ -134,10 +126,12 @@ class TestHomeRowSamples {
context = mockk(),
api = mockk(),
serverRepository = mockk(),
+ userPreferencesService = mockk(),
navDrawerService = mockk(),
+ latestNextUpService = mockk(),
+ imageUrlService = mockk(),
+ suggestionService = mockk(),
displayPreferencesService = mockk(),
- serverPluginApi = mockk(),
- serverDao = mockk(),
)
val str = """{
@@ -155,7 +149,7 @@ class TestHomeRowSamples {
]
}"""
- val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str)
+ val jsonElement = service.jsonParser.parseToJsonElement(str)
val settings = service.decode(jsonElement)
Assert.assertEquals(1, settings.rows.size)
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt
similarity index 61%
rename from app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt
rename to app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt
index 1de5becd..556a2fc0 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt
@@ -1,12 +1,14 @@
-package com.github.damontecres.wholphin
+package com.github.damontecres.wholphin.test
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.datastore.core.DataStore
+import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.data.CurrentUser
-import com.github.damontecres.wholphin.data.RestoredSession
+import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@@ -17,10 +19,8 @@ import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
-import com.github.damontecres.wholphin.test.currentUser
-import com.github.damontecres.wholphin.test.server
-import com.github.damontecres.wholphin.test.user
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
@@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.junit.After
-import org.junit.Assert
-import org.junit.Assert.assertFalse
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -58,6 +58,7 @@ class TestMainActivityViewModel {
private val backdropService: BackdropService = mockk(relaxed = true)
private val appUpgradeHandler: AppUpgradeHandler = mockk()
private val intentService: IntentService = mockk()
+ private val serverDao: JellyfinServerDao = mockk()
val viewModel =
MainActivityViewModel(
@@ -73,7 +74,6 @@ class TestMainActivityViewModel {
)
private val serverId = UUID.randomUUID()
- private val server = server(serverId)
private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId)
@@ -88,6 +88,13 @@ class TestMainActivityViewModel {
WholphinDispatchers.configure(testDispatcher)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
+ every { serverRepository.serverDao } returns serverDao
+ coEvery { serverDao.getServer(serverId) } returns
+ JellyfinServerUsers(
+ currentUser.server,
+ // User list is unused
+ emptyList(),
+ )
mockkStatic(Toast::class)
val mockToast = mockk()
every { Toast.makeText(any(), any(), any()) } returns mockToast
@@ -114,26 +121,22 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
-// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
-// coEvery { serverRepository.restoreLastSession() } returns
-// RestoredSession.Success(currentUser)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
- viewModel.handleAppStart()
+ viewModel.appStart(null)
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.AppContent,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
destination as SetupDestination.AppContent
- Assert.assertEquals(currentUser, destination.current)
+ assertEquals(currentUser, destination.current)
}
- coVerify(exactly = 0) { serverRepository.restoreLastSession() }
- coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@@ -141,27 +144,27 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = false
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
- coEvery { serverRepository.getMostRecentServer() } returns
- RestoredSession.ServerOnly(server)
- viewModel.handleAppStart()
+ viewModel.appStart(null)
+ advanceUntilIdle()
- coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
- coVerify(exactly = 0) { serverRepository.restoreLastSession() }
- coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
+ verify(exactly = 1) { serverDao.getServer(serverId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
val args = mutableListOf()
verify { setupNavigationManager.navigateTo(capture(args)) }
- Assert.assertEquals(1, args.size)
+ assertEquals(2, args.size)
args[0].let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.UserList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
+ }
+ args[1].let { destination ->
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
destination as SetupDestination.UserList
- Assert.assertEquals(currentUser.server, destination.server)
+ assertEquals(currentUser.server, destination.server)
}
}
@@ -170,30 +173,27 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
- every { serverRepository.current } returns
- MutableStateFlow(
- protectedCurrentUser,
- )
- coEvery {
- serverRepository.tryChangeUser(serverId, userId)
- } returns null
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.ServerOnly(server)
+ every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
- viewModel.handleAppStart()
+ viewModel.appStart(null)
+ advanceUntilIdle()
- coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ verify { serverDao.getServer(serverId) }
val args = mutableListOf()
verify { setupNavigationManager.navigateTo(capture(args)) }
- Assert.assertEquals(1, args.size)
+ assertEquals(2, args.size)
args[0].let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.UserList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
+ }
+ args[1].let { destination ->
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
destination as SetupDestination.UserList
- Assert.assertEquals(currentUser.server, destination.server)
+ assertEquals(currentUser.server, destination.server)
}
}
@@ -202,26 +202,22 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.ServerOnly(server)
- coEvery {
- serverRepository.tryChangeUser(serverId, userId)
- } returns null
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
- viewModel.handleAppStart()
+ viewModel.appStart(null)
+ advanceUntilIdle()
- coVerify(exactly = 1) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.UserList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
destination as SetupDestination.UserList
- Assert.assertEquals(currentUser.server, destination.server)
+ assertEquals(currentUser.server, destination.server)
}
}
@@ -230,49 +226,45 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
+ currentServerId = ""
+ currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
+ coEvery { serverRepository.restoreSession(null, null) } returns null
- viewModel.handleAppStart()
+ viewModel.appStart(null)
+ advanceUntilIdle()
- coVerify(exactly = 1) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.ServerList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
}
}
@Test
- fun `Test intent result is noop, hot reload`() =
+ fun `Test intent result is noop`() =
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreLastSession() } returns
- RestoredSession.Success(currentUser)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
- val result = viewModel.handleIntent(Intent())
- assertFalse(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
-// val slot = slot()
-// verify { setupNavigationManager.navigateTo(capture(slot)) }
-// slot.captured.let { destination ->
-// Assert.assertTrue(
-// "destination is ${destination::class}",
-// destination is SetupDestination.AppContent,
-// )
-// destination as SetupDestination.AppContent
-// Assert.assertEquals(currentUser, destination.current)
-// }
- coVerify(exactly = 0) { serverRepository.restoreLastSession() }
- coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
+ val slot = slot()
+ verify { setupNavigationManager.navigateTo(capture(slot)) }
+ slot.captured.let { destination ->
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
+ destination as SetupDestination.AppContent
+ assertEquals(currentUser, destination.current)
+ }
}
@Test
@@ -280,22 +272,22 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
- val result = viewModel.handleIntent(Intent())
- assertTrue(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.UserList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
destination as SetupDestination.UserList
- Assert.assertEquals(currentUser.server, destination.server)
+ assertEquals(currentUser.server, destination.server)
}
}
@@ -304,20 +296,20 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
- val result = viewModel.handleIntent(Intent())
- assertTrue(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.ServerList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
}
}
@@ -326,9 +318,11 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
-// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -337,27 +331,21 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- val result = viewModel.handleIntent(Intent())
- assertTrue(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify { navigationManager.reloadHome() }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.AppContent,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
destination as SetupDestination.AppContent
- Assert.assertEquals(currentUser, destination.current)
+ assertEquals(currentUser, destination.current)
}
// Note: navigation manager internally handles Home destination, so it won't be in this list
- Assert.assertEquals(1, backstack.size)
+ assertEquals(1, backstack.size)
backstack[0].let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is Destination.Favorites,
- )
+ assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
}
}
@@ -366,9 +354,11 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
-// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -377,26 +367,20 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- val result = viewModel.handleIntent(Intent())
- assertTrue(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.AppContent,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
destination as SetupDestination.AppContent
- Assert.assertEquals(currentUser, destination.current)
+ assertEquals(currentUser, destination.current)
}
- Assert.assertEquals(1, backstack.size)
+ assertEquals(1, backstack.size)
backstack[0].let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is Destination.Favorites,
- )
+ assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
}
}
@@ -405,9 +389,11 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
+ currentServerId = serverId.toServerString()
+ currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
-// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -416,17 +402,14 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- val result = viewModel.handleIntent(Intent())
- assertTrue(result)
+ viewModel.appStart(Intent())
+ advanceUntilIdle()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
- Assert.assertTrue(
- "destination is ${destination::class}",
- destination is SetupDestination.ServerList,
- )
+ assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
}
}
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt
deleted file mode 100644
index e58d8aec..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt
+++ /dev/null
@@ -1,534 +0,0 @@
-package com.github.damontecres.wholphin.test
-
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
-import com.github.damontecres.wholphin.data.model.ActivationFlag
-import com.github.damontecres.wholphin.data.model.ItemPlayback
-import com.github.damontecres.wholphin.data.model.JellyfinUser
-import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
-import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
-import com.github.damontecres.wholphin.data.model.TrackFlag
-import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
-import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.services.StreamChoiceReason
-import com.github.damontecres.wholphin.services.StreamChoiceService
-import com.github.damontecres.wholphin.services.audio
-import com.github.damontecres.wholphin.services.mockServerRepo
-import com.github.damontecres.wholphin.services.subtitle
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.test.runTest
-import org.jellyfin.sdk.model.UUID
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.MediaStreamType
-import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-
-class TestSeriesTrackChoice {
- private val mockServerRepository =
- mockServerRepo(
- audioLang = "eng",
- subtitleLang = "eng",
- subtitleMode = SubtitlePlaybackMode.DEFAULT,
- )
- private val mockSeriesTrackChoiceDao = mockk(relaxed = false)
-
- private val streamChoiceService =
- StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
-
- private val seriesId = UUID.randomUUID()
- private val season1Id = UUID.randomUUID()
- private val season2Id = UUID.randomUUID()
- private val season3Id = UUID.randomUUID()
-
- private val user =
- JellyfinUser(
- 1,
- UUID.randomUUID(),
- null,
- UUID.randomUUID(),
- null,
- )
-
- @Before
- fun before() {
- every { mockServerRepository.currentUser } returns user
- }
-
- @Test
- fun `Test StreamChoiceService call correct dao function`() =
- runTest {
- val mockSeriesTrackChoiceDao = mockk(relaxed = true)
- val streamChoiceService =
- StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
-
- val seriesId = UUID.randomUUID()
- val seasonId = UUID.randomUUID()
-
- // Has both series & season
- BaseItemDto(
- id = UUID.randomUUID(),
- type = BaseItemKind.EPISODE,
- seriesId = seriesId,
- parentId = seasonId,
- ).let { item ->
- streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
- coVerify(exactly = 1) {
- mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
- }
- }
-
- // Has only series
- BaseItemDto(
- id = UUID.randomUUID(),
- type = BaseItemKind.EPISODE,
- seriesId = seriesId,
- parentId = null,
- ).let { item ->
- streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
- coVerify(exactly = 1) {
- mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE)
- }
- }
-
- // Has only season
- BaseItemDto(
- id = UUID.randomUUID(),
- type = BaseItemKind.EPISODE,
- seriesId = null,
- parentId = seasonId,
- ).let { item ->
- streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
- coVerify(exactly = 1) {
- mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
- }
- }
- }
-
- @Test
- fun `Test series disabled`() =
- runTest {
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true),
- subtitle(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- ),
- ),
- )
- assertNull(result.stream)
- }
-
- @Test
- fun `Test series only forced`() =
- runTest {
- val mockServerRepository =
- mockServerRepo(
- audioLang = "eng",
- subtitleLang = "eng",
- subtitleMode = SubtitlePlaybackMode.NONE,
- )
- val streamChoiceService =
- StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
-
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs =
- UserPreferences(
- AppPreferences.getDefaultInstance(),
- JellyfinUserPreferences(),
- ),
- candidates =
- listOf(
- subtitle(0, "eng", forced = true),
- subtitle(1, "eng", forced = false),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ONLY_FORCED,
- ),
- ),
- )
- assertNotNull(result.stream)
- assertEquals(0, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Series)
- }
-
- @Test
- fun `Test series disabled with season`() =
- runTest {
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true),
- subtitle(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- ),
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- ),
- ),
- )
- assertNull(result.stream)
- assertTrue(result.reason is StreamChoiceReason.Series)
- }
-
- @Test
- fun `Test item playback overrides STC for subtitles`() =
- runTest {
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback =
- ItemPlayback(
- userId = user.rowId,
- itemId = UUID.randomUUID(),
- subtitleIndex = 1,
- ),
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true),
- subtitle(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- ),
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.DISABLED,
- ),
- ),
- )
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Item)
- }
-
- @Test
- fun `Test item playback overrides STC for audio`() =
- runTest {
- val result =
- streamChoiceService.chooseAudioStream(
- itemPlayback =
- ItemPlayback(
- userId = user.rowId,
- itemId = UUID.randomUUID(),
- audioIndex = 1,
- ),
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- audio(0, "eng", default = true),
- audio(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.AUDIO,
- language = "eng",
- ),
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.AUDIO,
- language = "eng",
- ),
- ),
- )
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Item)
- }
-
- @Test
- fun `Test season STC doesn't apply but series STC does - subtitle`() =
- runTest {
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback =
- ItemPlayback(
- userId = user.rowId,
- itemId = UUID.randomUUID(),
- audioIndex = 1,
- ),
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true),
- subtitle(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ONLY_FORCED,
- ),
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ACTIVATED,
- language = "spa",
- ),
- ),
- )
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Series)
- }
-
- @Test
- fun `Test season STC doesn't apply but series STC does - audio`() =
- runTest {
- val result =
- streamChoiceService.chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- audio(0, "eng", default = true),
- audio(1, "spa"),
- ),
- stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.AUDIO,
- activation = ActivationFlag.ONLY_FORCED,
- ),
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = seriesId,
- parentType = TrackChoiceParentType.SERIES,
- type = SeriesTrackChoiceType.AUDIO,
- activation = ActivationFlag.ACTIVATED,
- language = "spa",
- ),
- ),
- )
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Series)
- }
-
- @Test
- fun `Test STC has forced flag`() =
- runTest {
- val stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = listOf(TrackFlag.FORCED).calculateFlag(),
- ),
- )
-
- // Choose forced track
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true, forced = true),
- subtitle(1, "spa"),
- ),
- stc = stc,
- ).let { result ->
- assertNotNull(result.stream)
- assertEquals(0, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Season)
- }
-
- // No forced track to choose from, so nothing should be chosen
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = false, forced = false),
- subtitle(1, "spa"),
- ),
- stc = stc,
- ).let { result ->
- assertNull(result.stream)
- }
- }
-
- @Test
- fun `Test prefer SDH`() =
- runTest {
- val stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = listOf(TrackFlag.SDH).calculateFlag(),
- ),
- )
-
- // Prefer SDH over default and non-SDH
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true, forced = true),
- subtitle(1, "eng", default = false, forced = false, sdh = false),
- subtitle(2, "eng", default = false, forced = false, sdh = true),
- ),
- stc = stc,
- ).let { result ->
- assertNotNull(result.stream)
- assertEquals(2, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Season)
- }
-
- // No SDH, so prefer regular over forced
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true, forced = true),
- subtitle(1, "eng", default = false, forced = false, sdh = false),
- ),
- stc = stc,
- ).let { result ->
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Season)
- }
- }
-
- @Test
- fun `Test prefer default track with no other flags`() =
- runTest {
- val stc =
- listOf(
- SeriesTrackChoice(
- userId = user.rowId,
- parentId = season1Id,
- parentType = TrackChoiceParentType.SEASON,
- type = SeriesTrackChoiceType.SUBTITLE,
- activation = ActivationFlag.ACTIVATED,
- trackFlags = 0,
- ),
- )
-
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = true, forced = false),
- subtitle(1, "eng", default = false, forced = false),
- ),
- stc = stc,
- ).let { result ->
- assertNotNull(result.stream)
- assertEquals(0, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Season)
- }
-
- // Test out-of-order tracks
- streamChoiceService
- .chooseSubtitleStream(
- audioStreamLang = "eng",
- itemPlayback = null,
- prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
- candidates =
- listOf(
- subtitle(0, "eng", default = false, forced = false),
- subtitle(1, "eng", default = true, forced = false),
- ),
- stc = stc,
- ).let { result ->
- assertNotNull(result.stream)
- assertEquals(1, result.stream!!.index)
- assertTrue(result.reason is StreamChoiceReason.Season)
- }
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt
deleted file mode 100644
index d2013f19..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.github.damontecres.wholphin.test
-
-import com.github.damontecres.wholphin.data.model.TrackFlag
-import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import java.util.EnumSet
-
-class TestTrackFlag {
- @Test
- fun basicTest() {
- val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag()
- assertTrue(TrackFlag.DEFAULT.within(flag))
- assertTrue(TrackFlag.EXTERNAL.within(flag))
- assertFalse(TrackFlag.FORCED.within(flag))
- assertFalse(TrackFlag.SDH.within(flag))
- }
-
- @Test
- fun none() {
- val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag()
- assertFalse(TrackFlag.DEFAULT.within(flag))
- assertFalse(TrackFlag.EXTERNAL.within(flag))
- assertFalse(TrackFlag.FORCED.within(flag))
- assertFalse(TrackFlag.SDH.within(flag))
- }
-
- @Test
- fun all() {
- val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag()
- TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) }
- }
-
- @Test
- fun checkFlags() {
- TrackFlag.entries.forEach {
- assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0)
- }
- val distinctFlags = TrackFlag.entries.map { it.flag }.distinct()
- assertEquals(TrackFlag.entries.size, distinctFlags.size)
- }
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
index c291d067..34d962da 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
@@ -11,6 +11,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.NameGuidPair
import org.jellyfin.sdk.model.api.UserDto
+import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@@ -148,16 +149,3 @@ inline fun assertIs(obj: Any?) {
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
}
}
-
-/**
- * Asserts that the object is not null
- *
- * Includes a [contract] to smart-cast the object to T
- */
-@OptIn(ExperimentalContracts::class)
-fun assertNotNull(obj: Any?) {
- contract {
- returns() implies (obj != null)
- }
- org.junit.Assert.assertNotNull(obj)
-}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
index b266bb96..c4a54ec2 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
@@ -17,18 +17,15 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
-import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
-import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
-import com.github.damontecres.wholphin.services.TestMostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
@@ -204,10 +201,6 @@ object TestModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
-
- @Provides
- @Singleton
- fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider()
}
@Module
@@ -267,10 +260,6 @@ object TestDatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
- @Provides
- @Singleton
- fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
-
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
index ba7f1e14..bc879d24 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
@@ -9,7 +9,6 @@ import com.github.damontecres.wholphin.services.DeletedItem
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
-import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
@@ -35,7 +34,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
-import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.After
@@ -59,7 +57,6 @@ class CollectionFolderViewModelTest {
private val mockUserLibraryApi = mockk()
private val artistsRequest = slot()
- private val albumArtistsRequest = slot()
private val itemsRequest = slot()
private val libraryId = UUID.randomUUID()
@@ -86,14 +83,11 @@ class CollectionFolderViewModelTest {
// against the relaxed ApiClient
mockkObject(GetItemsRequestHandler)
mockkObject(GetArtistsHandler)
- mockkObject(GetAlbumArtistsHandler)
mockkObject(GetPersonsHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns
successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT)
coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns
successQueryResult(totalRecordCount = ARTIST_COUNT)
- coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns
- successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT)
coEvery { GetPersonsHandler.execute(mockApi, any()) } returns
successQueryResult(totalRecordCount = PERSON_COUNT)
}
@@ -103,36 +97,33 @@ class CollectionFolderViewModelTest {
WholphinDispatchers.reset()
unmockkObject(GetItemsRequestHandler)
unmockkObject(GetArtistsHandler)
- unmockkObject(GetAlbumArtistsHandler)
unmockkObject(GetPersonsHandler)
}
- private fun createViewModel(
- filter: GetItemsFilter,
- viewOptions: ViewOptions = ViewOptionsSquare,
- ) = CollectionFolderViewModel(
- savedStateHandle = SavedStateHandle(),
- api = mockApi,
- context = mockk(relaxed = true),
- serverRepository = mockServerRepository,
- libraryDisplayInfoDao = mockk(relaxed = true),
- favoriteWatchManager = mockk(relaxed = true),
- backdropService = mockk(relaxed = true),
- navigationManager = mockk(relaxed = true),
- themeSongPlayer = mockk(relaxed = true),
- userPreferencesService = mockk(relaxed = true),
- mediaManagementService = mockMediaManagementService,
- musicService = mockk(relaxed = true),
- streamChoiceService = mockk(relaxed = true),
- serverReportService = mockk(relaxed = true),
- filterOptionCache = mockk(relaxed = true),
- itemId = libraryId.toString(),
- initialSortAndDirection = null,
- recursive = true,
- collectionFilter = CollectionFolderFilter(filter = filter),
- useSeriesForPrimary = false,
- defaultViewOptions = viewOptions,
- )
+ private fun createViewModel(filter: GetItemsFilter) =
+ CollectionFolderViewModel(
+ savedStateHandle = SavedStateHandle(),
+ api = mockApi,
+ context = mockk(relaxed = true),
+ serverRepository = mockServerRepository,
+ libraryDisplayInfoDao = mockk(relaxed = true),
+ favoriteWatchManager = mockk(relaxed = true),
+ backdropService = mockk(relaxed = true),
+ navigationManager = mockk(relaxed = true),
+ themeSongPlayer = mockk(relaxed = true),
+ userPreferencesService = mockk(relaxed = true),
+ mediaManagementService = mockMediaManagementService,
+ musicService = mockk(relaxed = true),
+ streamChoiceService = mockk(relaxed = true),
+ serverReportService = mockk(relaxed = true),
+ filterOptionCache = mockk(relaxed = true),
+ itemId = libraryId.toString(),
+ initialSortAndDirection = null,
+ recursive = true,
+ collectionFilter = CollectionFolderFilter(filter = filter),
+ useSeriesForPrimary = false,
+ defaultViewOptions = ViewOptionsSquare,
+ )
/** Counting via /Items would return every song and album, overshooting the artist grid */
@Test
@@ -152,37 +143,6 @@ class CollectionFolderViewModelTest {
coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any()) }
}
- @Test
- fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() =
- runTest(testDispatcher) {
- val viewModel =
- createViewModel(
- GetItemsFilter(override = GetItemsFilterOverride.ARTIST),
- ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)),
- )
- advanceUntilIdle()
-
- val position = viewModel.positionOfLetter('K')
-
- assertEquals(ALBUM_ARTIST_COUNT, position)
- assertEquals("K", albumArtistsRequest.captured.nameLessThan)
- assertEquals(libraryId, albumArtistsRequest.captured.parentId)
- coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any()) }
- }
-
- @Test
- fun `changing the artist source reloads from the album artists endpoint`() =
- runTest(testDispatcher) {
- val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST))
- advanceUntilIdle()
- coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any()) }
-
- viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)))
- advanceUntilIdle()
-
- coVerify { GetAlbumArtistsHandler.execute(any(), any()) }
- }
-
/**
* Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists
* like "The Beatles" even though the grid sorts them under "B".
@@ -237,7 +197,6 @@ class CollectionFolderViewModelTest {
companion object {
private const val LIBRARY_ITEM_COUNT = 7315
private const val ARTIST_COUNT = 730
- private const val ALBUM_ARTIST_COUNT = 215
private const val PERSON_COUNT = 12
}
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt
deleted file mode 100644
index 511dbaa2..00000000
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package com.github.damontecres.wholphin.ui.playback
-
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.lifecycle.SavedStateHandle
-import com.github.damontecres.wholphin.data.ItemPlaybackDao
-import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.Playlist
-import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
-import com.github.damontecres.wholphin.preferences.AppPreferences
-import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
-import com.github.damontecres.wholphin.services.NavigationManager
-import com.github.damontecres.wholphin.services.PlaylistCreationResult
-import com.github.damontecres.wholphin.services.PlaylistCreator
-import com.github.damontecres.wholphin.services.StreamChoiceReason
-import com.github.damontecres.wholphin.services.StreamChoiceResult
-import com.github.damontecres.wholphin.services.StreamChoiceService
-import com.github.damontecres.wholphin.services.UserPreferencesService
-import com.github.damontecres.wholphin.test.TestTracks
-import com.github.damontecres.wholphin.test.movie
-import com.github.damontecres.wholphin.test.playlist
-import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp
-import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
-import com.github.damontecres.wholphin.ui.successResponse
-import com.github.damontecres.wholphin.util.LoadingState
-import com.github.damontecres.wholphin.util.WholphinDispatchers
-import com.github.damontecres.wholphin.util.configure
-import com.github.damontecres.wholphin.util.reset
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkStatic
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.runTest
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.playStateApi
-import org.jellyfin.sdk.api.client.extensions.subtitleApi
-import org.jellyfin.sdk.api.client.extensions.userLibraryApi
-import org.jellyfin.sdk.api.client.extensions.videosApi
-import org.jellyfin.sdk.api.operations.PlayStateApi
-import org.jellyfin.sdk.api.operations.SubtitleApi
-import org.jellyfin.sdk.api.operations.UserLibraryApi
-import org.jellyfin.sdk.api.operations.VideosApi
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.extensions.inWholeTicks
-import org.junit.After
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-import kotlin.time.Duration.Companion.minutes
-
-@RunWith(RobolectricTestRunner::class)
-@Config(manifest = Config.NONE, sdk = [30])
-class PlayExternalViewModelTest {
- private val testDispatcher = StandardTestDispatcher()
- private val context = mockk()
- private val api = mockk()
- private val userLibraryApi = mockk()
- private val videosApi = mockk()
- private val subtitleApi = mockk