v1.1.0
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings
-----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQOwlHRfQqaoMZESsY18BqmXMIMwtJzU8cAEB2FWHNv5x2j/ZsGi8ES7VNW+rYtgV/B KF9ctdqeDoocL8r8da+AY= -----END SSH SIGNATURE----- Merge upstream v1.1.0 while preserving Wolfskin customizations
This commit is contained in:
commit
670e95f2fa
159 changed files with 10810 additions and 2864 deletions
4
.github/actions/setup/action.yml
vendored
4
.github/actions/setup/action.yml
vendored
|
|
@ -6,11 +6,11 @@ runs:
|
||||||
steps:
|
steps:
|
||||||
# Setup the SDKs
|
# Setup the SDKs
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Setup JDK
|
- name: Setup JDK
|
||||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6
|
||||||
with:
|
with:
|
||||||
distribution: zulu
|
distribution: zulu
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
|
||||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||||
- name: Upload mapping files
|
- name: Upload mapping files
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||||
with:
|
with:
|
||||||
name: mapping
|
name: mapping
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Need the tags to build
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Run pre-commit
|
- name: Run pre-commit
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -59,14 +59,14 @@ jobs:
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||||
- name: Upload AAB
|
- name: Upload AAB
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||||
with:
|
with:
|
||||||
name: AAB
|
name: AAB
|
||||||
path: |
|
path: |
|
||||||
app/build/outputs/bundle/**/*.aab
|
app/build/outputs/bundle/**/*.aab
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
- name: Upload mapping files
|
- name: Upload mapping files
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||||
with:
|
with:
|
||||||
name: mapping
|
name: mapping
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ dependencies {
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.androidx.media3.ui.compose)
|
implementation(libs.androidx.media3.ui.compose)
|
||||||
implementation(libs.ass.media)
|
implementation(libs.ass.media)
|
||||||
|
implementation(libs.exoplayerhdrutils)
|
||||||
|
|
||||||
implementation(libs.coil.core)
|
implementation(libs.coil.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
|
||||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -22,3 +22,6 @@
|
||||||
-keep class androidx.media3.ui.SubtitlePainter {
|
-keep class androidx.media3.ui.SubtitlePainter {
|
||||||
private final float outlineWidth;
|
private final float outlineWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keep external/native functions
|
||||||
|
-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,834 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 40,
|
||||||
|
"identityHash": "b297c087485321c3ee8282ad1ff675a0",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pin",
|
||||||
|
"columnName": "pin",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "requireLogin",
|
||||||
|
"columnName": "requireLogin",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uiLanguage",
|
||||||
|
"columnName": "uiLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredAudioLanguage",
|
||||||
|
"columnName": "preferredAudioLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredSubtitleLanguage",
|
||||||
|
"columnName": "preferredSubtitleLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.subtitleMode",
|
||||||
|
"columnName": "subtitleMode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'USE_USER_PROFILE'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_id_serverId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemPlayback",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioIndex",
|
||||||
|
"columnName": "audioIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleIndex",
|
||||||
|
"columnName": "subtitleIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ItemPlayback_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "NavDrawerPinnedItem",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "-1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "LibraryDisplayInfo",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sort",
|
||||||
|
"columnName": "sort",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "direction",
|
||||||
|
"columnName": "direction",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filter",
|
||||||
|
"columnName": "filter",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'{}'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewOptions",
|
||||||
|
"columnName": "viewOptions",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_LibraryDisplayInfo_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playback_effects",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.rotation",
|
||||||
|
"columnName": "rotation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.brightness",
|
||||||
|
"columnName": "brightness",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.contrast",
|
||||||
|
"columnName": "contrast",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.saturation",
|
||||||
|
"columnName": "saturation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.hue",
|
||||||
|
"columnName": "hue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.red",
|
||||||
|
"columnName": "red",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.green",
|
||||||
|
"columnName": "green",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blue",
|
||||||
|
"columnName": "blue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blur",
|
||||||
|
"columnName": "blur",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"itemId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "PlaybackLanguageChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioLanguage",
|
||||||
|
"columnName": "audioLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleLanguage",
|
||||||
|
"columnName": "subtitleLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitlesDisabled",
|
||||||
|
"columnName": "subtitlesDisabled",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"seriesId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemTrackModification",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackIndex",
|
||||||
|
"columnName": "trackIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "delayMs",
|
||||||
|
"columnName": "delayMs",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId",
|
||||||
|
"trackIndex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seerr_servers_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authMethod",
|
||||||
|
"columnName": "authMethod",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "credential",
|
||||||
|
"columnName": "credential",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"serverId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seerr_servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"jellyfinUserRowId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "RememberedTab",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_RememberedTab_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "SeriesTrackChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentType",
|
||||||
|
"columnName": "parentType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "language",
|
||||||
|
"columnName": "language",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "activation",
|
||||||
|
"columnName": "activation",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackFlags",
|
||||||
|
"columnName": "trackFlags",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "codec",
|
||||||
|
"columnName": "codec",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "relativeTrackIndex",
|
||||||
|
"columnName": "relativeTrackIndex",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioChannels",
|
||||||
|
"columnName": "audioChannels",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"parentId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b297c087485321c3ee8282ad1ff675a0')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,841 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 41,
|
||||||
|
"identityHash": "1f2fe7912f9e3ab83b77029e07488e84",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pin",
|
||||||
|
"columnName": "pin",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "requireLogin",
|
||||||
|
"columnName": "requireLogin",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uiLanguage",
|
||||||
|
"columnName": "uiLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredAudioLanguage",
|
||||||
|
"columnName": "preferredAudioLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredSubtitleLanguage",
|
||||||
|
"columnName": "preferredSubtitleLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.subtitleMode",
|
||||||
|
"columnName": "subtitleMode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'USE_USER_PROFILE'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "config.homeSettingsSource",
|
||||||
|
"columnName": "c_homeSettingsSource",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'UNSET'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_id_serverId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemPlayback",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioIndex",
|
||||||
|
"columnName": "audioIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleIndex",
|
||||||
|
"columnName": "subtitleIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ItemPlayback_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "NavDrawerPinnedItem",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "-1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "LibraryDisplayInfo",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sort",
|
||||||
|
"columnName": "sort",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "direction",
|
||||||
|
"columnName": "direction",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filter",
|
||||||
|
"columnName": "filter",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'{}'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewOptions",
|
||||||
|
"columnName": "viewOptions",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_LibraryDisplayInfo_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playback_effects",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.rotation",
|
||||||
|
"columnName": "rotation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.brightness",
|
||||||
|
"columnName": "brightness",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.contrast",
|
||||||
|
"columnName": "contrast",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.saturation",
|
||||||
|
"columnName": "saturation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.hue",
|
||||||
|
"columnName": "hue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.red",
|
||||||
|
"columnName": "red",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.green",
|
||||||
|
"columnName": "green",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blue",
|
||||||
|
"columnName": "blue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blur",
|
||||||
|
"columnName": "blur",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"itemId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "PlaybackLanguageChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioLanguage",
|
||||||
|
"columnName": "audioLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleLanguage",
|
||||||
|
"columnName": "subtitleLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitlesDisabled",
|
||||||
|
"columnName": "subtitlesDisabled",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"seriesId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemTrackModification",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackIndex",
|
||||||
|
"columnName": "trackIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "delayMs",
|
||||||
|
"columnName": "delayMs",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId",
|
||||||
|
"trackIndex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seerr_servers_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authMethod",
|
||||||
|
"columnName": "authMethod",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "credential",
|
||||||
|
"columnName": "credential",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"serverId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seerr_servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"jellyfinUserRowId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "RememberedTab",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_RememberedTab_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "SeriesTrackChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentId",
|
||||||
|
"columnName": "parentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentType",
|
||||||
|
"columnName": "parentType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "language",
|
||||||
|
"columnName": "language",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "activation",
|
||||||
|
"columnName": "activation",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackFlags",
|
||||||
|
"columnName": "trackFlags",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "codec",
|
||||||
|
"columnName": "codec",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "relativeTrackIndex",
|
||||||
|
"columnName": "relativeTrackIndex",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioChannels",
|
||||||
|
"columnName": "audioChannels",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"parentId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f2fe7912f9e3ab83b77029e07488e84')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,5 +3,8 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="com.amazon.permission.media.session.voicecommandcontrol"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
7
app/src/firetv/AndroidManifest.xml
Normal file
7
app/src/firetv/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
|
<uses-permission android:name="com.amazon.permission.media.session.voicecommandcontrol" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import android.view.WindowManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -77,7 +78,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -86,7 +86,6 @@ import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -444,120 +443,27 @@ class MainActivityViewModel
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
try {
|
try {
|
||||||
val result = intent?.let { intentService.parseIntent(intent) }
|
handleAppUpgrade()
|
||||||
when (result) {
|
} catch (ex: Exception) {
|
||||||
is IntentResult.Error -> {
|
Timber.e(ex, "Error during app upgrade!")
|
||||||
val current = serverRepository.current.value
|
showToast(
|
||||||
val destination =
|
context,
|
||||||
if (current != null) {
|
"Error during app upgrade: ${ex.localizedMessage}",
|
||||||
SetupDestination.UserList(current.server)
|
)
|
||||||
} else {
|
}
|
||||||
SetupDestination.ServerList
|
|
||||||
}
|
|
||||||
setupNavigationManager.navigateTo(destination)
|
|
||||||
Timber.e("Error parsing intent: %s", result.message)
|
|
||||||
showToast(context, result.message)
|
|
||||||
return@withLock
|
|
||||||
}
|
|
||||||
|
|
||||||
is IntentResult.Target -> {
|
try {
|
||||||
val current = serverRepository.current.value
|
val result = handleIntent(intent)
|
||||||
if (current != null) {
|
if (result) {
|
||||||
Timber.i("Received valid intent, switching to AppContent")
|
Timber.i("appStart was handled by intent")
|
||||||
|
return@withLock
|
||||||
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) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error parsing intent")
|
Timber.e(ex, "Error parsing intent")
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
handleAppStart()
|
||||||
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) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error during appStart")
|
Timber.e(ex, "Error during appStart")
|
||||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
|
@ -582,6 +488,103 @@ class MainActivityViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an [Intent], navigating if needed
|
||||||
|
*
|
||||||
|
* @return true if the intent was handled, false if normal app start should proceed
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal suspend fun handleIntent(intent: Intent?): Boolean {
|
||||||
|
val result = intent?.let { intentService.parseIntent(intent) }
|
||||||
|
when (result) {
|
||||||
|
is IntentResult.Error -> {
|
||||||
|
val current = serverRepository.current.value
|
||||||
|
val destination =
|
||||||
|
if (current != null) {
|
||||||
|
SetupDestination.UserList(current.server)
|
||||||
|
} else {
|
||||||
|
SetupDestination.ServerList
|
||||||
|
}
|
||||||
|
setupNavigationManager.navigateTo(destination)
|
||||||
|
Timber.e("Error parsing intent: %s", result.message)
|
||||||
|
showToast(context, result.message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
is IntentResult.Target -> {
|
||||||
|
val current = serverRepository.current.value
|
||||||
|
if (current != null) {
|
||||||
|
Timber.i("Received valid intent, switching to AppContent")
|
||||||
|
|
||||||
|
if (result.addHomeToBackStack) {
|
||||||
|
navigationManager.reloadHome()
|
||||||
|
} else {
|
||||||
|
navigationManager.backStack.clear()
|
||||||
|
}
|
||||||
|
navigationManager.backStack.addAll(result.destinations)
|
||||||
|
|
||||||
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.AppContent(current),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// This should never happen, but just reset app state if it does
|
||||||
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
Timber.e("Error parsing intent, no user is active")
|
||||||
|
showToast(context, "An error occurred parsing the intent")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
IntentResult.NoOp,
|
||||||
|
null,
|
||||||
|
-> {
|
||||||
|
// Not handled
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleAppUpgrade() {
|
||||||
|
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||||
|
if (needUpgrade) {
|
||||||
|
showToast(
|
||||||
|
context,
|
||||||
|
context.getString(
|
||||||
|
R.string.updated_toast,
|
||||||
|
appUpgradeHandler.currentVersion.toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
appUpgradeHandler.run()
|
||||||
|
}
|
||||||
|
appUpgradeHandler.copySubfont(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle starting the app including auto sign in and navigating to the right starting page
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal suspend fun handleAppStart() {
|
||||||
|
val signInAutomatically = preferences.data.first().signInAutomatically
|
||||||
|
val currentUser = serverRepository.current.value
|
||||||
|
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
|
||||||
|
// Hot reload
|
||||||
|
Timber.v("Hot reload restoring state")
|
||||||
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.AppContent(currentUser),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val restoredSession =
|
||||||
|
if (signInAutomatically) {
|
||||||
|
Timber.v("Sign in automatically")
|
||||||
|
serverRepository.restoreLastSession()
|
||||||
|
} else {
|
||||||
|
serverRepository.getMostRecentServer()
|
||||||
|
}
|
||||||
|
Timber.d("Restore last session result=%s", restoredSession)
|
||||||
|
setupNavigationManager.navigateTo(restoredSession.destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val Destination?.isPlayback: Boolean
|
private val Destination?.isPlayback: Boolean
|
||||||
|
|
|
||||||
|
|
@ -122,5 +122,7 @@ class WholphinApplication :
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
|
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
|
||||||
|
|
||||||
|
val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry
|
||||||
import androidx.savedstate.SavedStateRegistryController
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
|
@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -75,8 +74,10 @@ class WholphinDreamService :
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
lifecycleScope.launchDefault {
|
lifecycleScope.launchDefault {
|
||||||
if (serverRepository.current.value == null) {
|
if (serverRepository.current.value == null) {
|
||||||
val prefs = preferencesDataStore.data.first()
|
val result = serverRepository.restoreLastSession()
|
||||||
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
|
if (result !is RestoredSession.Success) {
|
||||||
|
Timber.w("Could not restore user session")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
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 com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
|
@ -48,8 +49,9 @@ import java.util.UUID
|
||||||
SeerrServer::class,
|
SeerrServer::class,
|
||||||
SeerrUser::class,
|
SeerrUser::class,
|
||||||
RememberedTab::class,
|
RememberedTab::class,
|
||||||
|
SeriesTrackChoice::class,
|
||||||
],
|
],
|
||||||
version = 35,
|
version = 41,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -68,6 +70,8 @@ import java.util.UUID
|
||||||
AutoMigration(32, 33),
|
AutoMigration(32, 33),
|
||||||
AutoMigration(33, 34),
|
AutoMigration(33, 34),
|
||||||
AutoMigration(34, 35),
|
AutoMigration(34, 35),
|
||||||
|
AutoMigration(35, 40),
|
||||||
|
AutoMigration(40, 41),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -87,6 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||||
|
|
||||||
abstract fun rememberedTabDao(): RememberedTabDao
|
abstract fun rememberedTabDao(): RememberedTabDao
|
||||||
|
|
||||||
|
abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -28,7 +31,7 @@ class ItemPlaybackRepository
|
||||||
constructor(
|
constructor(
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
private val streamChoiceService: StreamChoiceService,
|
private val streamChoiceService: StreamChoiceService,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,54 +48,79 @@ class ItemPlaybackRepository
|
||||||
): ChosenStreams? =
|
): ChosenStreams? =
|
||||||
serverRepository.currentUser?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
return getChosenItemFromPlayback(item, itemPlayback, prefs)
|
||||||
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
|
* Get the chosen source & stream for the given item
|
||||||
*
|
*
|
||||||
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
|
* This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
|
||||||
*/
|
*/
|
||||||
fun getChosenItemFromPlayback(
|
fun getChosenItemFromPlayback(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
audioStc: List<SeriesTrackChoice>,
|
||||||
|
subtitleStc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): ChosenStreams? {
|
): ChosenStreams? {
|
||||||
|
Timber.v(
|
||||||
|
"For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
|
||||||
|
item.id,
|
||||||
|
itemPlayback != null,
|
||||||
|
audioStc.isNotEmpty(),
|
||||||
|
subtitleStc.isNotEmpty(),
|
||||||
|
)
|
||||||
val source =
|
val source =
|
||||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||||
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
val audioStream =
|
val audioResult =
|
||||||
streamChoiceService.chooseAudioStream(
|
streamChoiceService.chooseAudioStream(
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.AUDIO }
|
?.filter { it.type == MediaStreamType.AUDIO }
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
playbackLanguageChoice = plc,
|
stc = audioStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)
|
)
|
||||||
val subtitleStream =
|
val subtitleStream =
|
||||||
streamChoiceService.chooseSubtitleStream(
|
streamChoiceService.chooseSubtitleStream(
|
||||||
audioStreamLang = audioStream?.language,
|
audioStreamLang = audioResult.stream?.language,
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
playbackLanguageChoice = plc,
|
stc = subtitleStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)
|
)
|
||||||
return ChosenStreams(
|
return ChosenStreams(
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
audioStc = audioStc,
|
||||||
|
subtitleStc = subtitleStc,
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
source = source,
|
source = source,
|
||||||
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||||
audioStream = audioStream,
|
audioStream = audioResult,
|
||||||
subtitleStream = subtitleStream,
|
subtitleStream = subtitleStream,
|
||||||
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||||
)
|
)
|
||||||
|
|
@ -156,26 +184,36 @@ class ItemPlaybackRepository
|
||||||
Timber.v("Saving track selection %s", toSave)
|
Timber.v("Saving track selection %s", toSave)
|
||||||
toSave = saveItemPlayback(toSave)
|
toSave = saveItemPlayback(toSave)
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
if (seriesId != null || item.data.parentId != null) {
|
||||||
if (type == MediaStreamType.AUDIO) {
|
if (type == MediaStreamType.AUDIO) {
|
||||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
val stream =
|
||||||
|
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
streamChoiceService.updateAudio(item.data, stream.language!!)
|
streamChoiceService.saveSeriesTrackChoice(
|
||||||
|
item.data,
|
||||||
|
stream.type,
|
||||||
|
stream,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if (type == MediaStreamType.SUBTITLE) {
|
} else if (type == MediaStreamType.SUBTITLE) {
|
||||||
if (trackIndex == TrackIndex.DISABLED) {
|
if (trackIndex == TrackIndex.DISABLED) {
|
||||||
streamChoiceService.updateSubtitles(
|
streamChoiceService.saveDisabledSeriesTrackChoice(
|
||||||
item.data,
|
item.data,
|
||||||
subtitleLang = null,
|
MediaStreamType.SUBTITLE,
|
||||||
subtitlesDisabled = true,
|
)
|
||||||
|
} else if (trackIndex == TrackIndex.ONLY_FORCED) {
|
||||||
|
streamChoiceService.saveOnlyForcedSeriesTrackChoice(
|
||||||
|
item.data,
|
||||||
|
MediaStreamType.SUBTITLE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
val stream =
|
||||||
|
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||||
if (stream?.language != null) {
|
if (stream?.language != null) {
|
||||||
streamChoiceService.updateSubtitles(
|
streamChoiceService.saveSeriesTrackChoice(
|
||||||
item.data,
|
item.data,
|
||||||
stream.language!!,
|
stream.type,
|
||||||
subtitlesDisabled = false,
|
stream,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,9 +278,10 @@ class ItemPlaybackRepository
|
||||||
*/
|
*/
|
||||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||||
chosenStreams?.plc?.let {
|
val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
|
||||||
Timber.d("Deleting %s", it)
|
if (stc.isNotEmpty()) {
|
||||||
playbackLanguageChoiceDao.delete(it)
|
Timber.d("Deleting %s", stc)
|
||||||
|
seriesTrackChoiceDao.delete(stc)
|
||||||
}
|
}
|
||||||
chosenStreams?.itemPlayback?.let {
|
chosenStreams?.itemPlayback?.let {
|
||||||
Timber.d("Deleting %s", it)
|
Timber.d("Deleting %s", it)
|
||||||
|
|
@ -256,11 +295,12 @@ class ItemPlaybackRepository
|
||||||
*/
|
*/
|
||||||
data class ChosenStreams(
|
data class ChosenStreams(
|
||||||
val itemPlayback: ItemPlayback?,
|
val itemPlayback: ItemPlayback?,
|
||||||
val plc: PlaybackLanguageChoice?,
|
val audioStc: List<SeriesTrackChoice>,
|
||||||
|
val subtitleStc: List<SeriesTrackChoice>,
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val source: MediaSourceInfo,
|
val source: MediaSourceInfo,
|
||||||
val videoStream: MediaStream?,
|
val videoStream: MediaStream?,
|
||||||
val audioStream: MediaStream?,
|
val audioStream: StreamChoiceResult,
|
||||||
val subtitleStream: MediaStream?,
|
val subtitleStream: StreamChoiceResult,
|
||||||
val subtitlesDisabled: Boolean,
|
val subtitlesDisabled: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServer.None
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServer.Server
|
||||||
|
import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the status of the most recently used server
|
||||||
|
*
|
||||||
|
* This can be saved/retrieved from a [MostRecentServerProvider]
|
||||||
|
*/
|
||||||
|
sealed interface MostRecentServer {
|
||||||
|
/**
|
||||||
|
* There is no recently used server
|
||||||
|
*/
|
||||||
|
data object None : MostRecentServer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recently used server without a recent user
|
||||||
|
*/
|
||||||
|
data class Server(
|
||||||
|
val serverId: UUID,
|
||||||
|
val serverUrl: String,
|
||||||
|
) : MostRecentServer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recently used server and user
|
||||||
|
*/
|
||||||
|
data class ServerAndUser(
|
||||||
|
val serverId: UUID,
|
||||||
|
val serverUrl: String,
|
||||||
|
val userId: UUID,
|
||||||
|
val accessToken: String,
|
||||||
|
) : MostRecentServer {
|
||||||
|
override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(current: CurrentUser?): MostRecentServer =
|
||||||
|
if (current != null && current.user.accessToken.isNotNullOrBlank()) {
|
||||||
|
ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken)
|
||||||
|
} else if (current != null) {
|
||||||
|
Server(current.server.id, current.server.url)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MostRecentServerProvider {
|
||||||
|
/**
|
||||||
|
* Get the [MostRecentServer] from this provider
|
||||||
|
*/
|
||||||
|
fun get(): MostRecentServer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the [CurrentUser] as the most recent server
|
||||||
|
*/
|
||||||
|
suspend fun save(current: CurrentUser)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the most recent server and user
|
||||||
|
*/
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLear the most recent user, leaving the server
|
||||||
|
*/
|
||||||
|
suspend fun clearUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [MostRecentServerProvider] that stores in [SharedPreferences]
|
||||||
|
*/
|
||||||
|
class MostRecentServerSharedPreferences(
|
||||||
|
context: Context,
|
||||||
|
) : MostRecentServerProvider {
|
||||||
|
private val prefs = getServerSharedPreferences(context)
|
||||||
|
|
||||||
|
override fun get(): MostRecentServer {
|
||||||
|
val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
|
||||||
|
val serverUrl = prefs.getString(SERVER_URL_KEY, null)
|
||||||
|
val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull()
|
||||||
|
val accessToken = prefs.getString(USER_TOKEN_KEY, null)
|
||||||
|
return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) {
|
||||||
|
ServerAndUser(serverId, serverUrl, userId, accessToken)
|
||||||
|
} else if (serverId != null && serverUrl.isNotNullOrBlank()) {
|
||||||
|
Server(serverId, serverUrl)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(current: CurrentUser) {
|
||||||
|
prefs.edit(true) {
|
||||||
|
putString(SERVER_ID_KEY, current.server.id.toServerString())
|
||||||
|
putString(SERVER_URL_KEY, current.server.url)
|
||||||
|
putString(USER_ID_KEY, current.user.id.toServerString())
|
||||||
|
putString(USER_TOKEN_KEY, current.user.accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
prefs.edit(true) {
|
||||||
|
remove(SERVER_ID_KEY)
|
||||||
|
remove(SERVER_URL_KEY)
|
||||||
|
remove(USER_ID_KEY)
|
||||||
|
remove(USER_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearUser() {
|
||||||
|
prefs.edit(true) {
|
||||||
|
remove(USER_ID_KEY)
|
||||||
|
remove(USER_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences(
|
||||||
|
"${context.packageName}_server",
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SERVER_ID_KEY = "current.server.id"
|
||||||
|
private const val SERVER_URL_KEY = "current.server.url"
|
||||||
|
private const val USER_ID_KEY = "current.user.id"
|
||||||
|
private const val USER_TOKEN_KEY = "current.user.accessToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of restoring a session
|
||||||
|
*/
|
||||||
|
sealed interface RestoredSession {
|
||||||
|
val destination: SetupDestination
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session could not be restored
|
||||||
|
*/
|
||||||
|
data object None : RestoredSession {
|
||||||
|
override val destination: SetupDestination
|
||||||
|
get() = SetupDestination.ServerList
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the server should be restored, so a user still need to be chosen
|
||||||
|
*/
|
||||||
|
data class ServerOnly(
|
||||||
|
val server: JellyfinServer,
|
||||||
|
) : RestoredSession {
|
||||||
|
override val destination: SetupDestination
|
||||||
|
get() = SetupDestination.UserList(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session was restored successfully
|
||||||
|
*/
|
||||||
|
data class Success(
|
||||||
|
val currentUser: CurrentUser,
|
||||||
|
) : RestoredSession {
|
||||||
|
override val destination: SetupDestination
|
||||||
|
get() = SetupDestination.AppContent(currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,17 +8,27 @@ import androidx.room.Query
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Dao
|
@Dao
|
||||||
interface PlaybackLanguageChoiceDao {
|
interface PlaybackLanguageChoiceDao {
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
||||||
suspend fun get(
|
suspend fun get(
|
||||||
userId: Int,
|
userId: Int,
|
||||||
seriesId: UUID,
|
seriesId: UUID,
|
||||||
): PlaybackLanguageChoice?
|
): PlaybackLanguageChoice?
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
suspend fun save(plc: PlaybackLanguageChoice): Long
|
||||||
|
|
||||||
|
@Deprecated("Use SeriesTrackChoiceDao")
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(plc: PlaybackLanguageChoice)
|
fun delete(plc: PlaybackLanguageChoice)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
|
||||||
|
suspend fun getAll(
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<PlaybackLanguageChoice>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SeriesTrackChoiceDao {
|
||||||
|
@Query(
|
||||||
|
"SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC",
|
||||||
|
)
|
||||||
|
suspend fun get(
|
||||||
|
userId: Int,
|
||||||
|
seasonId: UUID,
|
||||||
|
seriesId: UUID,
|
||||||
|
type: SeriesTrackChoiceType,
|
||||||
|
): List<SeriesTrackChoice>
|
||||||
|
|
||||||
|
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId")
|
||||||
|
suspend fun getBySeasonId(
|
||||||
|
userId: Int,
|
||||||
|
seasonId: UUID,
|
||||||
|
type: SeriesTrackChoiceType,
|
||||||
|
): List<SeriesTrackChoice>
|
||||||
|
|
||||||
|
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId")
|
||||||
|
suspend fun getBySeriesId(
|
||||||
|
userId: Int,
|
||||||
|
seriesId: UUID,
|
||||||
|
type: SeriesTrackChoiceType,
|
||||||
|
): List<SeriesTrackChoice>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun save(stc: SeriesTrackChoice): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun save(stc: List<SeriesTrackChoice>): List<Long>
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(stc: List<SeriesTrackChoice>)
|
||||||
|
|
||||||
|
@Query("SELECT * from SeriesTrackChoice WHERE userId=:userId")
|
||||||
|
suspend fun getAll(userId: Int): List<SeriesTrackChoice>
|
||||||
|
|
||||||
|
@Query("SELECT count(*) from SeriesTrackChoice")
|
||||||
|
suspend fun count(): Int
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.data
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
import android.content.Context
|
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.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
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.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|
@ -49,8 +44,8 @@ class ServerRepository
|
||||||
val jellyfin: Jellyfin,
|
val jellyfin: Jellyfin,
|
||||||
val serverDao: JellyfinServerDao,
|
val serverDao: JellyfinServerDao,
|
||||||
val apiClient: ApiClient,
|
val apiClient: ApiClient,
|
||||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
|
||||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||||
) {
|
) {
|
||||||
private var _current = MutableStateFlow<CurrentUser?>(null)
|
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||||
val current: StateFlow<CurrentUser?> = _current
|
val current: StateFlow<CurrentUser?> = _current
|
||||||
|
|
@ -77,6 +72,8 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val serverPluginInstalled = MutableStateFlow<Boolean>(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||||
*
|
*
|
||||||
|
|
@ -88,10 +85,11 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||||
_current.value = 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(
|
suspend fun changeUser(
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
|
|
@ -119,70 +117,101 @@ class ServerRepository
|
||||||
)
|
)
|
||||||
serverDao.addOrUpdateServer(updatedServer)
|
serverDao.addOrUpdateServer(updatedServer)
|
||||||
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
||||||
userPreferencesDataStore.updateData {
|
|
||||||
it
|
|
||||||
.toBuilder()
|
|
||||||
.apply {
|
|
||||||
currentServerId = updatedServer.id.toServerString()
|
|
||||||
currentUserId = updatedUser.id.toServerString()
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||||
withContext(WholphinDispatchers.Main) {
|
mostRecentServerProvider.save(currentUser)
|
||||||
_current.value = currentUser
|
_current.value = currentUser
|
||||||
_currentUserDto.value = ServerUserConfig(userDto)
|
_currentUserDto.value = ServerUserConfig(userDto)
|
||||||
}
|
|
||||||
getServerSharedPreferences(context).edit(true) {
|
|
||||||
putString(SERVER_URL_KEY, updatedServer.url)
|
|
||||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
|
||||||
}
|
|
||||||
return@withContext currentUser
|
return@withContext currentUser
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores a session for the given server & user such as when the app reopens
|
* Try to restore the most recent session
|
||||||
*
|
*
|
||||||
* If user has a PIN, this returns false
|
* Does not check if auto sign-in is enabled, but does check if the user profile is protected
|
||||||
*/
|
*/
|
||||||
suspend fun restoreSession(
|
suspend fun restoreLastSession(): RestoredSession {
|
||||||
|
val recentServer = mostRecentServerProvider.get()
|
||||||
|
Timber.d("restoreLastSession: recentServer=%s", recentServer)
|
||||||
|
val result =
|
||||||
|
when (recentServer) {
|
||||||
|
MostRecentServer.None -> {
|
||||||
|
RestoredSession.None
|
||||||
|
}
|
||||||
|
|
||||||
|
is MostRecentServer.Server -> {
|
||||||
|
val server = serverDao.getServer(recentServer.serverId)
|
||||||
|
if (server != null) {
|
||||||
|
RestoredSession.ServerOnly(server.server)
|
||||||
|
} else {
|
||||||
|
RestoredSession.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MostRecentServer.ServerAndUser -> {
|
||||||
|
val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
|
||||||
|
if (currentUser != null) {
|
||||||
|
if (currentUser.user.isProtected) {
|
||||||
|
RestoredSession.ServerOnly(currentUser.server)
|
||||||
|
} else {
|
||||||
|
RestoredSession.Success(currentUser)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getMostRecentServerInternal(recentServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recently used server, if any
|
||||||
|
*/
|
||||||
|
suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
|
||||||
|
|
||||||
|
suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
|
||||||
|
val recentServer = recentServer ?: mostRecentServerProvider.get()
|
||||||
|
val serverId =
|
||||||
|
when (recentServer) {
|
||||||
|
MostRecentServer.None -> null
|
||||||
|
is MostRecentServer.Server -> recentServer.serverId
|
||||||
|
is MostRecentServer.ServerAndUser -> recentServer.serverId
|
||||||
|
}
|
||||||
|
val server = serverId?.let { serverDao.getServer(serverId)?.server }
|
||||||
|
return if (server != null) {
|
||||||
|
RestoredSession.ServerOnly(server)
|
||||||
|
} else {
|
||||||
|
RestoredSession.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to change to the given server & user. If the user's profile is protected, returns null
|
||||||
|
*
|
||||||
|
* @return the server/user or null if the session could not be restored
|
||||||
|
*/
|
||||||
|
suspend fun tryChangeUser(
|
||||||
serverId: UUID?,
|
serverId: UUID?,
|
||||||
userId: UUID?,
|
userId: UUID?,
|
||||||
): CurrentUser? {
|
): CurrentUser? =
|
||||||
if (serverId == null || userId == null) {
|
withContext(ioDispatcher) {
|
||||||
_current.value = null
|
return@withContext if (serverId == null || userId == null) {
|
||||||
return null
|
_current.value = null
|
||||||
}
|
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 {
|
} else {
|
||||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
val serverAndUsers = serverDao.getServer(serverId)
|
||||||
if (user != null) {
|
if (serverAndUsers != null) {
|
||||||
return changeUser(serverAndUsers.server, user)
|
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||||
|
if (user != null && !user.isProtected) {
|
||||||
|
changeUser(serverAndUsers.server, user)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
|
||||||
withContext(ioDispatcher) {
|
|
||||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeSession() {
|
|
||||||
_current.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a successful [AuthenticationResult], switch to the user that just authenticated
|
* Given a successful [AuthenticationResult], switch to the user that just authenticated
|
||||||
|
|
@ -244,13 +273,7 @@ class ServerRepository
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
userPreferencesDataStore.updateData {
|
mostRecentServerProvider.clearUser()
|
||||||
it
|
|
||||||
.toBuilder()
|
|
||||||
.apply {
|
|
||||||
currentUserId = ""
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
apiClient.update(accessToken = null)
|
apiClient.update(accessToken = null)
|
||||||
}
|
}
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
|
|
@ -263,14 +286,7 @@ class ServerRepository
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
userPreferencesDataStore.updateData {
|
mostRecentServerProvider.clear()
|
||||||
it
|
|
||||||
.toBuilder()
|
|
||||||
.apply {
|
|
||||||
currentServerId = ""
|
|
||||||
currentUserId = ""
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
apiClient.update(baseUrl = null, accessToken = null)
|
apiClient.update(baseUrl = null, accessToken = null)
|
||||||
}
|
}
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
|
|
@ -279,14 +295,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun switchServerOrUser() {
|
suspend fun switchServerOrUser() {
|
||||||
userPreferencesDataStore.updateData {
|
mostRecentServerProvider.clear()
|
||||||
it
|
|
||||||
.toBuilder()
|
|
||||||
.apply {
|
|
||||||
currentServerId = ""
|
|
||||||
currentUserId = ""
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateUserAuth(
|
suspend fun updateUserAuth(
|
||||||
|
|
@ -335,17 +344,6 @@ class ServerRepository
|
||||||
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
|
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
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ data class BaseItem(
|
||||||
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
|
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
|
||||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||||
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
|
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
|
||||||
|
BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ")
|
||||||
else -> data.productionYear?.toString()
|
else -> data.productionYear?.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.VideoType
|
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.GetArtistsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
|
|
@ -155,6 +156,22 @@ data class GetItemsFilter(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun applyTo(req: GetAlbumArtistsRequest) =
|
||||||
|
req.copy(
|
||||||
|
minCommunityRating = minCommunityRating,
|
||||||
|
isFavorite = favorite,
|
||||||
|
genreIds = genres,
|
||||||
|
personIds = persons,
|
||||||
|
studioIds = studios,
|
||||||
|
tags = tags,
|
||||||
|
officialRatings = officialRatings,
|
||||||
|
years =
|
||||||
|
buildSet {
|
||||||
|
years?.letNotEmpty(::addAll)
|
||||||
|
decades?.forEach { addAll(it..<(it + 10)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
@file:UseSerializers(UUIDSerializer::class)
|
@file:UseSerializers(
|
||||||
|
UUIDSerializer::class,
|
||||||
|
DateTimeSerializer::class,
|
||||||
|
)
|
||||||
|
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
|
@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
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.api.request.GetItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -174,19 +181,45 @@ sealed interface HomeRowConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An arbitrary [GetItemsRequest] allowing to query for anything
|
* An arbitrary [HomeGetItemsFilter] allowing to query for most anything
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("GetItems")
|
@SerialName("GetItems")
|
||||||
data class GetItems(
|
data class GetItems(
|
||||||
val name: String,
|
val name: String,
|
||||||
val getItems: GetItemsRequest,
|
val filter: HomeGetItemsFilter,
|
||||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
) : HomeRowConfig {
|
) : HomeRowConfig {
|
||||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
|
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>.
|
||||||
|
* 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<KeyValueEntry>? = null,
|
||||||
|
val query: List<KeyValueEntry>? = 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
|
* Root class for home page settings
|
||||||
*
|
*
|
||||||
|
|
@ -241,3 +274,54 @@ data class HomeRowViewOptions(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of [GetItemsFilter]
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class HomeGetItemsFilter(
|
||||||
|
val sortBy: List<ItemSortBy>? = null,
|
||||||
|
val sortOrder: List<SortOrder>? = null,
|
||||||
|
val ids: List<UUID>? = null,
|
||||||
|
val minCommunityRating: Double? = null,
|
||||||
|
val minCriticRating: Double? = null,
|
||||||
|
val recursive: Boolean? = null,
|
||||||
|
val searchTerm: String? = null,
|
||||||
|
val parentId: UUID? = null,
|
||||||
|
val includeItemTypes: List<BaseItemKind>? = null,
|
||||||
|
val isFavorite: Boolean? = null,
|
||||||
|
val isPlayed: Boolean? = null,
|
||||||
|
val officialRatings: Collection<String>? = null,
|
||||||
|
val tags: List<String>? = null,
|
||||||
|
val years: List<Int>? = null,
|
||||||
|
val personIds: List<UUID>? = null,
|
||||||
|
val minOfficialRating: String? = null,
|
||||||
|
val hasOfficialRating: Boolean? = null,
|
||||||
|
val studioIds: List<UUID>? = null,
|
||||||
|
val genreIds: List<UUID>? = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.room.Relation
|
||||||
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
|
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
|
||||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||||
|
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
|
|
@ -68,6 +69,7 @@ data class JellyfinUser(
|
||||||
val lastUsed: ZonedDateTime? = null,
|
val lastUsed: ZonedDateTime? = null,
|
||||||
val uiLanguage: String? = null,
|
val uiLanguage: String? = null,
|
||||||
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||||
|
@Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
|
||||||
) {
|
) {
|
||||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||||
|
|
||||||
|
|
@ -77,9 +79,17 @@ data class JellyfinUser(
|
||||||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
|
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
|
||||||
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
|
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
|
||||||
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
|
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
|
||||||
"appPreferences=$appPreferences"
|
"appPreferences=$appPreferences, config=$config"
|
||||||
|
|
||||||
|
fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class JellyfinUserConfig(
|
||||||
|
@ColumnInfo(defaultValue = "UNSET")
|
||||||
|
val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ data class Person(
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
role = personRole(context, dto.role, dto.type),
|
role = personRole(context, dto.role, dto.type),
|
||||||
type = dto.type,
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
|
||||||
favorite = false,
|
favorite = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ data class Person(
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
role = personRole(context, dto.role, dto.type),
|
role = personRole(context, dto.role, dto.type),
|
||||||
type = dto.type,
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
|
||||||
favorite = favorite,
|
favorite = favorite,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* DEPRECATED
|
||||||
|
*
|
||||||
* Stores the language choices for a series so they can be applied automatically to other episodes
|
* 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
|
* without the user needing to explicitly choose the tracks
|
||||||
|
*
|
||||||
|
* @see SeriesTrackChoice
|
||||||
*/
|
*/
|
||||||
|
@Deprecated("Use SeriesTrackChoice")
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [MediaStream] with its relative track index, its position within a specific stream type
|
||||||
|
*/
|
||||||
|
data class RelativeMediaStream(
|
||||||
|
val relativeIndex: Int,
|
||||||
|
val stream: MediaStream,
|
||||||
|
) {
|
||||||
|
val type: MediaStreamType get() = stream.type
|
||||||
|
val language: String? get() = stream.language
|
||||||
|
val isForced: Boolean get() = stream.isForced
|
||||||
|
val isExternal: Boolean get() = stream.isExternal
|
||||||
|
val isDefault: Boolean get() = stream.isDefault
|
||||||
|
val isHearingImpaired: Boolean get() = stream.isHearingImpaired
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getRelativeIndex(
|
||||||
|
index: Int,
|
||||||
|
streams: List<MediaStream>,
|
||||||
|
): Int? {
|
||||||
|
val stream = streams.firstOrNull { it.index == index } ?: return null
|
||||||
|
return getRelativeIndex(stream, streams)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRelativeIndex(
|
||||||
|
stream: MediaStream,
|
||||||
|
streams: List<MediaStream>,
|
||||||
|
): 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<MediaStream>,
|
||||||
|
): 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<MediaStream>.asRelative(): List<RelativeMediaStream> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -54,3 +54,11 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
|
||||||
permission.parents.any { hasPermission(it) } ||
|
permission.parents.any { hasPermission(it) } ||
|
||||||
SeerrPermission.ADMIN.hasPermission(permissions)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface SeerrPluginLogin {
|
||||||
|
@SerialName("None")
|
||||||
|
@Serializable
|
||||||
|
data object None : SeerrPluginLogin
|
||||||
|
|
||||||
|
@SerialName("ApiKey")
|
||||||
|
@Serializable
|
||||||
|
data class ApiKey(
|
||||||
|
val apiKey: String,
|
||||||
|
) : SeerrPluginLogin
|
||||||
|
|
||||||
|
@SerialName("Local")
|
||||||
|
@Serializable
|
||||||
|
data class Local(
|
||||||
|
val local: SeerrPluginLocalLogin,
|
||||||
|
) : SeerrPluginLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SeerrPluginLocalLogin(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SeerrPluginSettings(
|
||||||
|
val version: Int = 1,
|
||||||
|
val serverUrl: String? = null,
|
||||||
|
val login: SeerrPluginLogin? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
@file:UseSerializers(UUIDSerializer::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import com.github.damontecres.wholphin.services.isSigns
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks
|
||||||
|
*
|
||||||
|
* Additional information about the chosen track is stored to compare against tracks in other media
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = JellyfinUser::class,
|
||||||
|
parentColumns = arrayOf("rowId"),
|
||||||
|
childColumns = arrayOf("userId"),
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
onUpdate = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
primaryKeys = ["userId", "parentId", "type"],
|
||||||
|
)
|
||||||
|
@Serializable
|
||||||
|
data class SeriesTrackChoice(
|
||||||
|
val userId: Int,
|
||||||
|
/**
|
||||||
|
* Season or Series
|
||||||
|
*/
|
||||||
|
val parentId: UUID,
|
||||||
|
/**
|
||||||
|
* Type of track, ie audio or subtitle
|
||||||
|
*/
|
||||||
|
val type: SeriesTrackChoiceType,
|
||||||
|
/**
|
||||||
|
* Origin of the [parentId]
|
||||||
|
*/
|
||||||
|
val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN,
|
||||||
|
val itemId: UUID? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val activation: ActivationFlag = ActivationFlag.ACTIVATED,
|
||||||
|
/**
|
||||||
|
* Flags for the track such as external or forced
|
||||||
|
*
|
||||||
|
* @see [TrackFlag]
|
||||||
|
*/
|
||||||
|
val trackFlags: Int = 0,
|
||||||
|
val codec: String? = null,
|
||||||
|
/**
|
||||||
|
* The track index based on its position within all streams of the same type
|
||||||
|
*
|
||||||
|
* @see [RelativeMediaStream]
|
||||||
|
*/
|
||||||
|
val relativeTrackIndex: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val audioChannels: Int? = null,
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
userId: Int,
|
||||||
|
itemId: UUID,
|
||||||
|
parentId: UUID,
|
||||||
|
type: SeriesTrackChoiceType,
|
||||||
|
parentType: TrackChoiceParentType,
|
||||||
|
stream: RelativeMediaStream?,
|
||||||
|
activation: ActivationFlag,
|
||||||
|
) : this(
|
||||||
|
userId = userId,
|
||||||
|
parentId = parentId,
|
||||||
|
parentType = parentType,
|
||||||
|
type = type,
|
||||||
|
itemId = itemId,
|
||||||
|
language = stream?.language,
|
||||||
|
activation = activation,
|
||||||
|
trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0,
|
||||||
|
codec = stream?.stream?.codec,
|
||||||
|
relativeTrackIndex = stream?.relativeIndex,
|
||||||
|
title = stream?.stream?.title,
|
||||||
|
audioChannels = stream?.stream?.channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s
|
||||||
|
*/
|
||||||
|
fun from(plc: PlaybackLanguageChoice): List<SeriesTrackChoice> =
|
||||||
|
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<TrackFlag>.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,
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
|
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
|
|
@ -345,7 +346,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
|
|
||||||
const val MEGA_BIT = 1024 * 1024L
|
const val MEGA_BIT = 1024 * 1024L
|
||||||
const val DEFAULT_BITRATE = 100 * MEGA_BIT
|
const val DEFAULT_BITRATE = 100 * MEGA_BIT
|
||||||
private val bitrateValues =
|
val bitrateValues =
|
||||||
listOf(
|
listOf(
|
||||||
500 * 1024L,
|
500 * 1024L,
|
||||||
750 * 1024L,
|
750 * 1024L,
|
||||||
|
|
@ -378,11 +379,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summarizer = { value ->
|
summarizer = { value ->
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
|
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
|
||||||
if (v < MEGA_BIT) {
|
formatMaxBitrate(v)
|
||||||
"${v / 1024} kbps"
|
|
||||||
} else {
|
|
||||||
"${v / MEGA_BIT} Mbps"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -456,15 +453,69 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
val DirectPlayDoviProfile7 =
|
val Hdr10Override =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppChoicePreference<AppPreferences, HdrOverrideMode>(
|
||||||
title = R.string.force_dovi_profile_7,
|
title = R.string.hdr10_override,
|
||||||
defaultValue = false,
|
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
|
||||||
getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
|
getter = { it.playbackPreferences.overrides.hdr10Override },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value }
|
prefs.updatePlaybackOverrides { hdr10Override = value }
|
||||||
},
|
},
|
||||||
summary = R.string.force_dovi_profile_7_summary,
|
displayValues = R.array.hdr_override_modes,
|
||||||
|
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
|
||||||
|
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
val Hdr10PlusOverride =
|
||||||
|
AppChoicePreference<AppPreferences, HdrOverrideMode>(
|
||||||
|
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<AppPreferences, HdrOverrideMode>(
|
||||||
|
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<AppPreferences, HdrOverrideMode>(
|
||||||
|
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<AppPreferences, HdrOverrideMode>(
|
||||||
|
title = R.string.dovi_profile_8,
|
||||||
|
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
|
||||||
|
getter = { it.playbackPreferences.overrides.doviProfile8Override },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePlaybackOverrides { doviProfile8Override = value }
|
||||||
|
},
|
||||||
|
displayValues = R.array.hdr_override_modes,
|
||||||
|
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
|
||||||
|
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
val DecodeAv1 =
|
val DecodeAv1 =
|
||||||
|
|
@ -919,6 +970,12 @@ sealed interface AppPreference<Pref, T> {
|
||||||
destination = Destination.Settings(PreferenceScreenOption.MPV),
|
destination = Destination.Settings(PreferenceScreenOption.MPV),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val HdrFormatSettings =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.hdr_formats,
|
||||||
|
destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
|
||||||
|
)
|
||||||
|
|
||||||
val MpvHardwareDecoding =
|
val MpvHardwareDecoding =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.mpv_hardware_decoding,
|
title = R.string.mpv_hardware_decoding,
|
||||||
|
|
@ -1183,8 +1240,17 @@ private val ExoPlayerSettings =
|
||||||
AppPreference.Ac3Supported,
|
AppPreference.Ac3Supported,
|
||||||
AppPreference.AssSubtitleMode,
|
AppPreference.AssSubtitleMode,
|
||||||
AppPreference.DirectPlayPgs,
|
AppPreference.DirectPlayPgs,
|
||||||
AppPreference.DirectPlayDoviProfile7,
|
|
||||||
AppPreference.DecodeAv1,
|
AppPreference.DecodeAv1,
|
||||||
|
AppPreference.HdrFormatSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val HdrFormatSettings =
|
||||||
|
listOf(
|
||||||
|
AppPreference.Hdr10Override,
|
||||||
|
AppPreference.Hdr10PlusOverride,
|
||||||
|
AppPreference.DoviProfile5Override,
|
||||||
|
AppPreference.DoviProfile7Override,
|
||||||
|
AppPreference.DoviProfile8Override,
|
||||||
)
|
)
|
||||||
|
|
||||||
val ExoPlayerPreferences =
|
val ExoPlayerPreferences =
|
||||||
|
|
@ -1195,6 +1261,14 @@ val ExoPlayerPreferences =
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val HdrFormatPreferences =
|
||||||
|
listOf(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.hdr_formats,
|
||||||
|
preferences = HdrFormatSettings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private val MpvSettings =
|
private val MpvSettings =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.MpvHardwareDecoding,
|
AppPreference.MpvHardwareDecoding,
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,8 @@ class AppPreferencesSerializer
|
||||||
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||||
preferDolbyVisionOverHdr10Plus =
|
preferDolbyVisionOverHdr10Plus =
|
||||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
||||||
|
convertDolbyVisionProfile7 =
|
||||||
|
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,17 @@ object ExperimentalPreference {
|
||||||
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
|
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ConvertDolbyVisionProfile7 =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
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 =
|
val PreferAc3ForSurround =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.prefer_ac3_for_surround,
|
title = R.string.prefer_ac3_for_surround,
|
||||||
|
|
@ -93,6 +104,7 @@ val experimentalPreferences =
|
||||||
listOf(
|
listOf(
|
||||||
ExperimentalPreference.VideoTunneling,
|
ExperimentalPreference.VideoTunneling,
|
||||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
||||||
|
ExperimentalPreference.ConvertDolbyVisionProfile7,
|
||||||
ExperimentalPreference.PreferAc3ForSurround,
|
ExperimentalPreference.PreferAc3ForSurround,
|
||||||
ExperimentalPreference.DisableAudioOffload,
|
ExperimentalPreference.DisableAudioOffload,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package com.github.damontecres.wholphin.preferences
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
|
||||||
|
|
||||||
|
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HDR formats that the user can override in the device profile.
|
||||||
|
*/
|
||||||
|
enum class HdrFormat(
|
||||||
|
val getter: (AppPreferences) -> HdrOverrideMode,
|
||||||
|
val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set<String>,
|
||||||
|
) {
|
||||||
|
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<String> =
|
||||||
|
HdrFormat.entries
|
||||||
|
.filter { it.getter(this) == mode }
|
||||||
|
.flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) }
|
||||||
|
|
@ -6,14 +6,20 @@ import android.os.Build
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
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.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
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.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
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.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
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.ScreensaverPreference
|
||||||
import com.github.damontecres.wholphin.preferences.update
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
|
|
@ -36,6 +42,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -56,6 +63,9 @@ class AppUpgradeHandler
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
private val serverDao: JellyfinServerDao,
|
private val serverDao: JellyfinServerDao,
|
||||||
private val rememberedTabDao: RememberedTabDao,
|
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 pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||||
|
|
@ -442,5 +452,59 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
|
||||||
|
try {
|
||||||
|
val prefs = appPreferences.data.firstOrNull()
|
||||||
|
val serverId = prefs?.currentServerId?.toUUIDOrNull()
|
||||||
|
val userId = prefs?.currentUserId?.toUUIDOrNull()
|
||||||
|
if (serverId != null && userId != null) {
|
||||||
|
val server = serverDao.getServer(serverId)
|
||||||
|
val user = server?.users?.firstOrNull { it.id == userId }
|
||||||
|
if (server != null && user != null) {
|
||||||
|
Timber.i("Migrating most recent server")
|
||||||
|
mostRecentServerProvider.save(CurrentUser(server.server, user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error during migration for most recent server")
|
||||||
|
}
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.update {
|
||||||
|
currentServerId = ""
|
||||||
|
currentUserId = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) {
|
||||||
|
// directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward
|
||||||
|
appPreferences.updateData {
|
||||||
|
if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) {
|
||||||
|
it.updatePlaybackOverrides {
|
||||||
|
doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
|
||||||
|
directPlayDolbyVisionEL = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
|
||||||
|
val count = seriesTrackChoiceDao.count()
|
||||||
|
if (count == 0) {
|
||||||
|
var offset = 0
|
||||||
|
while (true) {
|
||||||
|
val plcs = playbackLanguageChoiceDao.getAll(100, offset)
|
||||||
|
if (plcs.isEmpty()) break
|
||||||
|
offset += plcs.size
|
||||||
|
|
||||||
|
val stcs = plcs.flatMap(SeriesTrackChoice::from)
|
||||||
|
Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
|
||||||
|
seriesTrackChoiceDao.save(stcs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
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.PlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.enabled
|
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.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||||
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
||||||
|
|
@ -61,9 +65,22 @@ class DeviceProfileService
|
||||||
downMixAudio = newConfig.overrides.downmixStereo,
|
downMixAudio = newConfig.overrides.downmixStereo,
|
||||||
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||||
pgsDirectPlay = newConfig.overrides.directPlayPgs,
|
pgsDirectPlay = newConfig.overrides.directPlayPgs,
|
||||||
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
|
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
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,
|
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||||
maxResolution = newConfig.overrides.maxResolution,
|
maxResolution = newConfig.overrides.maxResolution,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,765 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
|
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||||
|
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||||
|
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||||
|
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.ui.HomeItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.ProgramItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||||
|
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||||
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||||
|
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.DateTime
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@Singleton
|
||||||
|
class HomeDataService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val latestNextUpService: LatestNextUpService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val suggestionService: SuggestionService,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Fetch the data from the server for a given [HomeRowConfig]
|
||||||
|
*/
|
||||||
|
suspend fun fetchDataForRow(
|
||||||
|
row: HomeRowConfig,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
prefs: HomePagePreferences,
|
||||||
|
userDto: ServerUserConfig,
|
||||||
|
libraries: List<Library>,
|
||||||
|
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<BaseItemDto> {
|
||||||
|
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<BaseItemDtoQueryResult>(it.body.byteStream())
|
||||||
|
Timber.v("Got %s items from custom endpoint", queryResult.items.size)
|
||||||
|
queryResult.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Library?.itemFields: List<ItemFields>
|
||||||
|
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<BaseItemKind>?
|
||||||
|
get() =
|
||||||
|
collectionType.let { collectionType ->
|
||||||
|
getTypeFor(collectionType)?.let { type -> listOf(type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
|
||||||
|
|
@ -2,48 +2,27 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.github.damontecres.wholphin.R
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
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.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.DefaultUserConfiguration
|
||||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
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.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.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.StringProvider
|
import com.github.damontecres.wholphin.ui.util.StringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
@ -55,32 +34,15 @@ import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
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.userApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
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.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
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 timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles getting home page settings and data
|
* Handles getting home page settings and data
|
||||||
|
|
@ -92,21 +54,11 @@ class HomeSettingsService
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
|
||||||
private val navDrawerService: NavDrawerService,
|
private val navDrawerService: NavDrawerService,
|
||||||
private val latestNextUpService: LatestNextUpService,
|
|
||||||
private val imageUrlService: ImageUrlService,
|
|
||||||
private val suggestionService: SuggestionService,
|
|
||||||
private val displayPreferencesService: DisplayPreferencesService,
|
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
|
* The current home page settings
|
||||||
*/
|
*/
|
||||||
|
|
@ -188,9 +140,8 @@ class HomeSettingsService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
|
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
|
||||||
*
|
|
||||||
* This is public only for testing
|
|
||||||
*/
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
fun decode(element: JsonElement): HomePageSettings {
|
fun decode(element: JsonElement): HomePageSettings {
|
||||||
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
|
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
|
||||||
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
|
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
|
||||||
|
|
@ -211,6 +162,42 @@ class HomeSettingsService
|
||||||
return HomePageSettings(rows, version)
|
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]
|
* Loads [HomePageSettings] into [currentSettings]
|
||||||
*
|
*
|
||||||
|
|
@ -218,55 +205,70 @@ class HomeSettingsService
|
||||||
*
|
*
|
||||||
* Does not persist either the server nor default
|
* Does not persist either the server nor default
|
||||||
*/
|
*/
|
||||||
suspend fun loadCurrentSettings(userId: UUID) {
|
suspend fun loadCurrentSettings(user: JellyfinUser) {
|
||||||
Timber.v("Getting setting for %s", userId)
|
currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||||
// User local then server/remote otherwise create a default
|
|
||||||
|
Timber.v("Getting setting for %s", user.id)
|
||||||
val settings =
|
val settings =
|
||||||
try {
|
try {
|
||||||
val local = loadFromLocal(userId)
|
fetchSettingsFor(user.id, user.config.homeSettingsSource)
|
||||||
Timber.v("Found local? %s", local != null)
|
} catch (ex: CancellationException) {
|
||||||
local
|
throw ex
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error loading local settings")
|
Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
|
||||||
// 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
|
null
|
||||||
}
|
}
|
||||||
val resolvedSettings =
|
Timber.v("Found settings: %s", settings != null)
|
||||||
if (settings != null) {
|
val resolvedSettings = settings ?: createDefault(user.id)
|
||||||
Timber.v("Found settings")
|
|
||||||
// Resolve
|
|
||||||
val resolvedRows =
|
|
||||||
settings.rows.mapIndexed { index, config ->
|
|
||||||
resolve(index, config)
|
|
||||||
}
|
|
||||||
HomePageResolvedSettings(userId, resolvedRows)
|
|
||||||
} else {
|
|
||||||
createDefault(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSettings.update { resolvedSettings }
|
currentSettings.update { resolvedSettings }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to load settings from local->server->plugin
|
||||||
|
*/
|
||||||
|
private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> {
|
||||||
|
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
|
* Resolve the settings and set them to be the current settings
|
||||||
*/
|
*/
|
||||||
suspend fun updateCurrent(
|
suspend fun updateCurrent(
|
||||||
userId: UUID,
|
source: HomePageSettingsSource,
|
||||||
settings: HomePageSettings,
|
settings: HomePageSettings,
|
||||||
) {
|
) {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
settings.rows.mapIndexed { index, config ->
|
||||||
resolve(index, config)
|
resolve(index, config)
|
||||||
}
|
}
|
||||||
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
|
currentSettings.update { it.copy(rows = resolvedRows) }
|
||||||
currentSettings.update { resolvedSettings }
|
serverRepository.currentUser?.let { user ->
|
||||||
|
val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
|
||||||
|
serverDao.updateUser(toSave)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -316,7 +318,7 @@ class HomeSettingsService
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val rowConfig = continueWatchingRow + includedIds
|
val rowConfig = continueWatchingRow + includedIds
|
||||||
return HomePageResolvedSettings(userId, rowConfig)
|
return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -432,7 +434,7 @@ class HomeSettingsService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
HomePageResolvedSettings(userId, rowConfigs)
|
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -560,6 +562,14 @@ class HomeSettingsService
|
||||||
config,
|
config,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is HomeRowConfig.CustomEndpoint -> {
|
||||||
|
HomeRowConfigDisplay(
|
||||||
|
id = id,
|
||||||
|
title = StringStringProvider(config.title),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getItemName(
|
private suspend fun getItemName(
|
||||||
|
|
@ -585,631 +595,16 @@ class HomeSettingsService
|
||||||
ResStringProvider(R.string.unknown)
|
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<Library>,
|
|
||||||
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 {
|
companion object {
|
||||||
const val CUSTOM_PREF_ID = "home_settings"
|
const val CUSTOM_PREF_ID = "home_settings"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
val jsonParser =
|
||||||
|
Json {
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
allowTrailingComma = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1229,10 +624,14 @@ data class HomeRowConfigDisplay(
|
||||||
*/
|
*/
|
||||||
data class HomePageResolvedSettings(
|
data class HomePageResolvedSettings(
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
|
val source: HomePageSettingsSource,
|
||||||
val rows: List<HomeRowConfigDisplay>,
|
val rows: List<HomeRowConfigDisplay>,
|
||||||
) {
|
) {
|
||||||
|
fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
|
val EMPTY =
|
||||||
|
HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1266,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
|
||||||
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||||
?: ResStringProvider(R.string.recently_added)
|
?: ResStringProvider(R.string.recently_added)
|
||||||
|
|
||||||
private val Library?.itemFields: List<ItemFields>
|
|
||||||
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 =
|
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
|
||||||
when (collectionType) {
|
when (collectionType) {
|
||||||
CollectionType.MUSIC,
|
CollectionType.MUSIC,
|
||||||
|
|
@ -1315,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
|
||||||
HomeRowViewOptions()
|
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),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ class ImageUrlService
|
||||||
parentBackdropId: UUID? = null,
|
parentBackdropId: UUID? = null,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
|
seriesPrimaryTag: String? = null,
|
||||||
|
parentLogoId: UUID? = null,
|
||||||
|
parentLogoTag: String? = null,
|
||||||
|
parentThumbTag: String? = null,
|
||||||
|
parentBackdropTags: List<String> = emptyList(),
|
||||||
): String? =
|
): String? =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.LOGO -> {
|
ImageType.LOGO -> {
|
||||||
|
|
@ -44,6 +49,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = parentLogoTag.takeIf { parentLogoId == seriesId },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -51,6 +57,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = imageTags[imageType],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +70,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
|
||||||
)
|
)
|
||||||
} else if (backdropTags.isNotEmpty()) {
|
} else if (backdropTags.isNotEmpty()) {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -70,6 +78,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = backdropTags.first(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -86,6 +95,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = parentThumbTag,
|
||||||
)
|
)
|
||||||
} else if (useSeriesForPrimary && parentBackdropId != null &&
|
} else if (useSeriesForPrimary && parentBackdropId != null &&
|
||||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||||
|
|
@ -96,6 +106,7 @@ class ImageUrlService
|
||||||
imageType = ImageType.BACKDROP,
|
imageType = ImageType.BACKDROP,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = parentBackdropTags.firstOrNull(),
|
||||||
)
|
)
|
||||||
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -103,6 +114,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = parentThumbTag,
|
||||||
)
|
)
|
||||||
} else if (useSeriesForPrimary &&
|
} else if (useSeriesForPrimary &&
|
||||||
parentThumbId == null &&
|
parentThumbId == null &&
|
||||||
|
|
@ -115,6 +127,7 @@ class ImageUrlService
|
||||||
imageType = ImageType.PRIMARY,
|
imageType = ImageType.PRIMARY,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = imageTags[ImageType.PRIMARY],
|
||||||
)
|
)
|
||||||
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
|
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
|
||||||
// If no thumb, use backdrop if available
|
// If no thumb, use backdrop if available
|
||||||
|
|
@ -123,6 +136,7 @@ class ImageUrlService
|
||||||
imageType = ImageType.BACKDROP,
|
imageType = ImageType.BACKDROP,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = backdropTags.first(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -130,6 +144,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = imageTags[imageType],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +160,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
|
||||||
)
|
)
|
||||||
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -152,6 +168,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
|
|
@ -159,6 +176,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = imageTags[imageType],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +187,7 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
tag = imageTags[imageType],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +212,11 @@ class ImageUrlService
|
||||||
backdropTags = item.data.backdropImageTags.orEmpty(),
|
backdropTags = item.data.backdropImageTags.orEmpty(),
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
seriesPrimaryTag = item.data.seriesPrimaryImageTag,
|
||||||
|
parentLogoId = item.data.parentLogoItemId,
|
||||||
|
parentLogoTag = item.data.parentLogoImageTag,
|
||||||
|
parentThumbTag = item.data.parentThumbImageTag,
|
||||||
|
parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.app.SearchManager
|
import android.app.SearchManager
|
||||||
import android.content.Intent
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
|
|
@ -114,6 +116,7 @@ class IntentService
|
||||||
return IntentResult.Target(destinations, true)
|
return IntentResult.Target(destinations, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
internal suspend fun prepare(intent: Intent): IntentResult? {
|
internal suspend fun prepare(intent: Intent): IntentResult? {
|
||||||
val appPrefs = userPreferencesService.flow.first().appPreferences
|
val appPrefs = userPreferencesService.flow.first().appPreferences
|
||||||
|
|
||||||
|
|
@ -121,39 +124,33 @@ class IntentService
|
||||||
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
|
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
|
||||||
return if (userId != null && serverId != null) {
|
return if (userId != null && serverId != null) {
|
||||||
Timber.v("Intent switches user")
|
Timber.v("Intent switches user")
|
||||||
val user = serverRepository.serverDao.getUser(serverId, userId)
|
val user = serverRepository.tryChangeUser(serverId, userId)
|
||||||
if (user != null && !user.isProtected) {
|
if (user != null) {
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
?: IntentResult.Error("Error restoring user")
|
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
IntentResult.Error("Cannot switch to specified user")
|
IntentResult.Error("Error restoring user")
|
||||||
}
|
}
|
||||||
} else {
|
} else if (appPrefs.signInAutomatically) {
|
||||||
val profileProtected =
|
Timber.v("No current user, so restoring last")
|
||||||
serverRepository.current.value
|
val result = serverRepository.restoreLastSession()
|
||||||
?.user
|
Timber.v("Restored result=%s", result)
|
||||||
?.isProtected == true
|
when (result) {
|
||||||
if (appPrefs.signInAutomatically && !profileProtected) {
|
RestoredSession.None,
|
||||||
Timber.v("No current user, so restoring last")
|
is RestoredSession.ServerOnly,
|
||||||
val userId = appPrefs.currentUserId?.toUUIDOrNull()
|
-> {
|
||||||
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
|
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||||
|
}
|
||||||
|
|
||||||
if (userId != null && serverId != null) {
|
is RestoredSession.Success -> {
|
||||||
val current =
|
if (result.currentUser.user.isProtected) {
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
?: return IntentResult.Error("Error restoring user")
|
|
||||||
if (current.user.isProtected) {
|
|
||||||
IntentResult.Error("Could not auto sign-in, user is protected")
|
IntentResult.Error("Could not auto sign-in, user is protected")
|
||||||
} else {
|
} else {
|
||||||
null
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
|
@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -52,12 +54,8 @@ class LatestNextUpWorker
|
||||||
try {
|
try {
|
||||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||||
// Not active
|
// Not active
|
||||||
var currentUser = serverRepository.current.value
|
val result = serverRepository.restoreLastSession()
|
||||||
if (currentUser == null) {
|
if (result !is RestoredSession.Success) {
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
currentUser = serverRepository.current.value
|
|
||||||
}
|
|
||||||
if (currentUser == null) {
|
|
||||||
Timber.w("No user found during run")
|
Timber.w("No user found during run")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
|
||||||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exposed for testing
|
@VisibleForTesting
|
||||||
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,11 @@ class MusicService
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (mediaSession == null) {
|
if (mediaSession == null) {
|
||||||
Timber.i("Starting music MediaSession")
|
Timber.i("Starting music MediaSession")
|
||||||
mediaSession = MediaSession.Builder(context, player).build()
|
mediaSession =
|
||||||
|
MediaSession
|
||||||
|
.Builder(context, player)
|
||||||
|
.setCallback(TrustAllControllersCallback)
|
||||||
|
.build()
|
||||||
activityTracker =
|
activityTracker =
|
||||||
TrackActivityPlaybackListener(api, player) {
|
TrackActivityPlaybackListener(api, player) {
|
||||||
state.value.currentItemId?.let { itemId ->
|
state.value.currentItemId?.let { itemId ->
|
||||||
|
|
@ -327,6 +331,7 @@ class MusicService
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
itemId = albumId,
|
itemId = albumId,
|
||||||
imageType = ImageType.PRIMARY,
|
imageType = ImageType.PRIMARY,
|
||||||
|
tag = audio.data.albumPrimaryImageTag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return MediaItem
|
return MediaItem
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import androidx.media3.extractor.ExtractorsFactory
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -32,9 +33,15 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.get
|
import com.github.damontecres.wholphin.preferences.get
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
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.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.Hdr10PlusMaskingFilter
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import io.github.peerless2012.ass.media.AssHandler
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||||
|
|
@ -57,6 +64,7 @@ class PlayerFactory
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
|
private val deviceProfileService: DeviceProfileService,
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
var currentPlayer: Player? = null
|
var currentPlayer: Player? = null
|
||||||
|
|
@ -94,10 +102,16 @@ class PlayerFactory
|
||||||
val preferDolbyVision =
|
val preferDolbyVision =
|
||||||
appPreferences.experimentalPreferences
|
appPreferences.experimentalPreferences
|
||||||
.get { preferDolbyVisionOverHdr10Plus } ?: false
|
.get { preferDolbyVisionOverHdr10Plus } ?: false
|
||||||
|
val convertDolbyVisionProfile7 =
|
||||||
|
DolbyVisionProfile7Conversion.isWanted(
|
||||||
|
appPreferences.experimentalPreferences,
|
||||||
|
deviceProfileService.mediaCodecCapabilitiesTest,
|
||||||
|
)
|
||||||
Timber.v(
|
Timber.v(
|
||||||
"extensions=%s, assPlaybackMode=%s",
|
"extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
|
||||||
extensions,
|
extensions,
|
||||||
prefs.overrides.assPlaybackMode,
|
prefs.overrides.assPlaybackMode,
|
||||||
|
convertDolbyVisionProfile7,
|
||||||
)
|
)
|
||||||
val rendererMode =
|
val rendererMode =
|
||||||
when (extensions) {
|
when (extensions) {
|
||||||
|
|
@ -108,9 +122,18 @@ class PlayerFactory
|
||||||
}
|
}
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||||
val extractorsFactory = createExtractorsFactory()
|
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 =
|
var renderersFactory: RenderersFactory =
|
||||||
WholphinRenderersFactory(context, decodeAv1, preferDolbyVision)
|
WholphinRenderersFactory(
|
||||||
.setEnableDecoderFallback(true)
|
context,
|
||||||
|
decodeAv1,
|
||||||
|
preferDolbyVision,
|
||||||
|
convertDolbyVisionProfile7,
|
||||||
|
).setEnableDecoderFallback(true)
|
||||||
.setExtensionRendererMode(rendererMode)
|
.setExtensionRendererMode(rendererMode)
|
||||||
|
|
||||||
val mediaSourceFactory =
|
val mediaSourceFactory =
|
||||||
|
|
@ -126,15 +149,16 @@ class PlayerFactory
|
||||||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||||
DefaultMediaSourceFactory(
|
DefaultMediaSourceFactory(
|
||||||
dataSourceFactory,
|
dataSourceFactory,
|
||||||
extractorsFactory.withAssMkvSupport(
|
extractorsFactory
|
||||||
assSubtitleParserFactory,
|
.withAssMkvSupport(
|
||||||
assHandler,
|
assSubtitleParserFactory,
|
||||||
),
|
assHandler,
|
||||||
|
).forDolbyVision(),
|
||||||
).setSubtitleParserFactory(assSubtitleParserFactory)
|
).setSubtitleParserFactory(assSubtitleParserFactory)
|
||||||
} else {
|
} else {
|
||||||
DefaultMediaSourceFactory(
|
DefaultMediaSourceFactory(
|
||||||
dataSourceFactory,
|
dataSourceFactory,
|
||||||
extractorsFactory,
|
extractorsFactory.forDolbyVision(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val disableAudioOffload =
|
val disableAudioOffload =
|
||||||
|
|
@ -160,8 +184,9 @@ class PlayerFactory
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
.build(),
|
.build(),
|
||||||
false,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,9 +271,29 @@ class PlayerFactory
|
||||||
fun createMediaSession(player: Player) =
|
fun createMediaSession(player: Player) =
|
||||||
MediaSession
|
MediaSession
|
||||||
.Builder(context, player)
|
.Builder(context, player)
|
||||||
|
.setCallback(TrustAllControllersCallback)
|
||||||
.build()
|
.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<MediaSession.ConnectionResult> =
|
||||||
|
Futures.immediateFuture(
|
||||||
|
MediaSession.ConnectionResult
|
||||||
|
.AcceptedResultBuilder(session, controller)
|
||||||
|
.setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val Player.isReleased: Boolean
|
val Player.isReleased: Boolean
|
||||||
get() {
|
get() {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
|
|
@ -268,6 +313,7 @@ class WholphinRenderersFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val av1Enabled: Boolean,
|
private val av1Enabled: Boolean,
|
||||||
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
|
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
|
||||||
|
private val convertDolbyVisionProfile7: Boolean = false,
|
||||||
) : DefaultRenderersFactory(context) {
|
) : DefaultRenderersFactory(context) {
|
||||||
@OptIn(ExperimentalApi::class)
|
@OptIn(ExperimentalApi::class)
|
||||||
override fun buildVideoRenderers(
|
override fun buildVideoRenderers(
|
||||||
|
|
@ -280,12 +326,16 @@ class WholphinRenderersFactory(
|
||||||
allowedVideoJoiningTimeMs: Long,
|
allowedVideoJoiningTimeMs: Long,
|
||||||
out: ArrayList<Renderer>,
|
out: ArrayList<Renderer>,
|
||||||
) {
|
) {
|
||||||
|
// 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<BitstreamFilter> {
|
||||||
|
if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
|
||||||
|
if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
|
||||||
|
}
|
||||||
val videoCodecAdapterFactory =
|
val videoCodecAdapterFactory =
|
||||||
if (preferDolbyVisionOverHdr10Plus) {
|
if (bitstreamFilters.isNotEmpty()) {
|
||||||
BitstreamFilteringCodecAdapterFactory(
|
BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
|
||||||
codecAdapterFactory,
|
|
||||||
listOf(Hdr10PlusMaskingFilter()),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
codecAdapterFactory
|
codecAdapterFactory
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
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.SeerrServer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
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.ui.setup.seerr.createSeerrApiUrl
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -39,26 +43,25 @@ class SeerrServerRepository
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
private val _connection =
|
private val _connection = MutableStateFlow<SeerrState>(SeerrState())
|
||||||
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
val connection: StateFlow<SeerrState> = _connection
|
||||||
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
|
||||||
|
|
||||||
val current: Flow<CurrentSeerr?> =
|
val current: Flow<CurrentSeerr?> =
|
||||||
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
|
_connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
|
||||||
val currentServer: Flow<SeerrServer?> =
|
val currentServer: Flow<SeerrServer?> =
|
||||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
|
||||||
val currentUser: Flow<SeerrUser?> =
|
val currentUser: Flow<SeerrUser?> =
|
||||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
|
||||||
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
|
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Seerr integration is currently active of not
|
* Whether Seerr integration is currently active of not
|
||||||
*/
|
*/
|
||||||
val active: Flow<Boolean> =
|
val active: Flow<Boolean> =
|
||||||
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_connection.update { SeerrConnectionStatus.NotConfigured }
|
_connection.update { SeerrState() }
|
||||||
seerrApi.update("", null)
|
seerrApi.update("", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +70,7 @@ class SeerrServerRepository
|
||||||
user: SeerrUser,
|
user: SeerrUser,
|
||||||
exception: Exception,
|
exception: Exception,
|
||||||
) {
|
) {
|
||||||
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
|
_connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
|
||||||
seerrApi.update("", null)
|
seerrApi.update("", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,8 +81,9 @@ class SeerrServerRepository
|
||||||
) {
|
) {
|
||||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||||
_connection.update {
|
_connection.update {
|
||||||
SeerrConnectionStatus.Success(
|
it.copy(
|
||||||
CurrentSeerr(server, user, userConfig, publicSettings),
|
connection =
|
||||||
|
SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +173,7 @@ class SeerrServerRepository
|
||||||
|
|
||||||
suspend fun removeServerForCurrentUser(): Boolean {
|
suspend fun removeServerForCurrentUser(): Boolean {
|
||||||
val user =
|
val user =
|
||||||
when (val conn = connection.first()) {
|
when (val conn = connection.value.connection) {
|
||||||
SeerrConnectionStatus.NotConfigured -> return false
|
SeerrConnectionStatus.NotConfigured -> return false
|
||||||
is SeerrConnectionStatus.Error -> conn.user
|
is SeerrConnectionStatus.Error -> conn.user
|
||||||
is SeerrConnectionStatus.Success -> conn.current.user
|
is SeerrConnectionStatus.Success -> conn.current.user
|
||||||
|
|
@ -178,6 +182,81 @@ class SeerrServerRepository
|
||||||
clear()
|
clear()
|
||||||
return rows > 0
|
return rows > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the [SeerrPluginSettings], try to connect to the Seerr server
|
||||||
|
*
|
||||||
|
* @return true if successfully updated the SeerrApi and logged in
|
||||||
|
*/
|
||||||
|
suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
|
||||||
|
val currentUser = serverRepository.currentUser ?: return false
|
||||||
|
if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
|
||||||
|
val server =
|
||||||
|
SeerrServer(
|
||||||
|
id = -1,
|
||||||
|
url = settings.serverUrl,
|
||||||
|
)
|
||||||
|
val user =
|
||||||
|
SeerrUser(
|
||||||
|
jellyfinUserRowId = currentUser.rowId,
|
||||||
|
serverId = server.id,
|
||||||
|
authMethod =
|
||||||
|
when (settings.login) {
|
||||||
|
is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
|
||||||
|
is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
|
||||||
|
},
|
||||||
|
username =
|
||||||
|
when (settings.login) {
|
||||||
|
is SeerrPluginLogin.ApiKey -> null
|
||||||
|
is SeerrPluginLogin.Local -> settings.login.local.username
|
||||||
|
},
|
||||||
|
password = null,
|
||||||
|
credential = null,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val userConfig =
|
||||||
|
when (settings.login) {
|
||||||
|
is SeerrPluginLogin.ApiKey -> {
|
||||||
|
Timber.v("maybeAddPluginServer, using API key")
|
||||||
|
seerrApi.update(settings.serverUrl, settings.login.apiKey)
|
||||||
|
seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SeerrPluginLogin.Local -> {
|
||||||
|
Timber.v("maybeAddPluginServer, using local user")
|
||||||
|
seerrApi.update(settings.serverUrl, null)
|
||||||
|
seerrLogin(
|
||||||
|
seerrApi.api,
|
||||||
|
SeerrAuthMethod.LOCAL,
|
||||||
|
settings.login.local.username,
|
||||||
|
settings.login.local.password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrPluginLogin.None -> {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Success
|
||||||
|
_connection.update { it.copy(pluginProvided = true) }
|
||||||
|
set(server, user, userConfig)
|
||||||
|
return true
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
clear()
|
||||||
|
Timber.w(ex, "Error adding plugin provided seerr")
|
||||||
|
_connection.update {
|
||||||
|
it.copy(
|
||||||
|
pluginProvided = true,
|
||||||
|
connection = SeerrConnectionStatus.Error(server, user, ex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -185,6 +264,11 @@ class SeerrServerRepository
|
||||||
*/
|
*/
|
||||||
typealias SeerrUserConfig = User
|
typealias SeerrUserConfig = User
|
||||||
|
|
||||||
|
data class SeerrState(
|
||||||
|
val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
|
||||||
|
val pluginProvided: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface SeerrConnectionStatus {
|
sealed interface SeerrConnectionStatus {
|
||||||
data object NotConfigured : SeerrConnectionStatus
|
data object NotConfigured : SeerrConnectionStatus
|
||||||
|
|
||||||
|
|
@ -205,11 +289,15 @@ data class CurrentSeerr(
|
||||||
val config: SeerrUserConfig,
|
val config: SeerrUserConfig,
|
||||||
val serverConfig: PublicSettings,
|
val serverConfig: PublicSettings,
|
||||||
) {
|
) {
|
||||||
|
val requestMovieEnabled: Boolean
|
||||||
|
get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
|
||||||
val request4kMovieEnabled: Boolean
|
val request4kMovieEnabled: Boolean
|
||||||
get() =
|
get() =
|
||||||
(serverConfig.movie4kEnabled ?: false) &&
|
(serverConfig.movie4kEnabled ?: false) &&
|
||||||
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
|
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
|
||||||
|
|
||||||
|
val requestTvEnabled: Boolean
|
||||||
|
get() = config.hasPermission(SeerrPermission.REQUEST_TV)
|
||||||
val request4kTvEnabled: Boolean
|
val request4kTvEnabled: Boolean
|
||||||
get() =
|
get() =
|
||||||
(serverConfig.series4kEnabled ?: false) &&
|
(serverConfig.series4kEnabled ?: false) &&
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.IOException
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for interacting with the companion plugin
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ServerPluginApi
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
private fun createUrl(path: String): String? =
|
||||||
|
api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" }
|
||||||
|
|
||||||
|
private val json get() = HomeSettingsService.jsonParser
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val HOME_CONFIG_PATH = "homesettings"
|
||||||
|
private const val SEERR_CONFIG_PATH = "seerrsettings"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the plugin is installed on the server
|
||||||
|
*/
|
||||||
|
suspend fun checkInstalled(): Boolean =
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
val url = createUrl("public") ?: return@withContext false
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
return@withContext okHttpClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { it.isSuccessful }
|
||||||
|
} catch (ex: IOException) {
|
||||||
|
Timber.w("Could not connect to server plugin: %s", ex.message)
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> executeRequest(path: String): T? {
|
||||||
|
val url = createUrl(path) ?: return null
|
||||||
|
val request = Request(url.toHttpUrl())
|
||||||
|
return executeRequest<T>(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private suspend inline fun <reified T> executeRequest(request: Request): T? =
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
okHttpClient.newCall(request).execute().use { res ->
|
||||||
|
if (res.isSuccessful) {
|
||||||
|
json.decodeFromStream<T>(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<HomePageSettings>(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)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackFlag
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.data.model.asRelative
|
||||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
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.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
@ -30,50 +42,69 @@ class StreamChoiceService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
) {
|
) {
|
||||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
||||||
|
|
||||||
suspend fun updateAudio(
|
suspend fun saveSeriesTrackChoice(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
audioLang: String,
|
type: MediaStreamType,
|
||||||
) = update(dto) {
|
stream: RelativeMediaStream?,
|
||||||
it.copy(
|
activation: ActivationFlag = ActivationFlag.ACTIVATED,
|
||||||
audioLanguage = audioLang,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateSubtitles(
|
|
||||||
dto: BaseItemDto,
|
|
||||||
subtitleLang: String?,
|
|
||||||
subtitlesDisabled: Boolean,
|
|
||||||
) = update(dto) {
|
|
||||||
it.copy(
|
|
||||||
subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
|
|
||||||
subtitlesDisabled = subtitlesDisabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun update(
|
|
||||||
dto: BaseItemDto,
|
|
||||||
update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
|
|
||||||
) {
|
) {
|
||||||
val seriesId = dto.seriesId
|
val type =
|
||||||
if (seriesId != null) {
|
when (stream?.type ?: type) {
|
||||||
val userId = serverRepository.currentUser!!.rowId
|
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
|
||||||
val currentPlc =
|
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
|
||||||
playbackLanguageChoiceDao.get(userId, seriesId)
|
else -> return
|
||||||
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
}
|
||||||
val newPlc = update.invoke(currentPlc)
|
val userId = serverRepository.currentUser!!.rowId
|
||||||
Timber.v("Saving series PLC: %s", newPlc)
|
val newStc =
|
||||||
playbackLanguageChoiceDao.save(newPlc)
|
buildList {
|
||||||
}
|
dto.parentId
|
||||||
|
?.let { seasonId ->
|
||||||
|
SeriesTrackChoice(
|
||||||
|
userId = userId,
|
||||||
|
itemId = dto.id,
|
||||||
|
parentId = seasonId,
|
||||||
|
type = type,
|
||||||
|
parentType = TrackChoiceParentType.SEASON,
|
||||||
|
stream = stream,
|
||||||
|
activation = activation,
|
||||||
|
)
|
||||||
|
}?.let(::add)
|
||||||
|
dto.seriesId
|
||||||
|
?.let { seriesId ->
|
||||||
|
SeriesTrackChoice(
|
||||||
|
userId = userId,
|
||||||
|
itemId = dto.id,
|
||||||
|
parentId = seriesId,
|
||||||
|
type = type,
|
||||||
|
parentType = TrackChoiceParentType.SERIES,
|
||||||
|
stream = stream,
|
||||||
|
activation = activation,
|
||||||
|
)
|
||||||
|
}?.let(::add)
|
||||||
|
}
|
||||||
|
Timber.d(
|
||||||
|
"Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
|
||||||
|
dto.id,
|
||||||
|
dto.seriesId,
|
||||||
|
dto.parentId,
|
||||||
|
newStc,
|
||||||
|
)
|
||||||
|
seriesTrackChoiceDao.save(newStc)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
|
suspend fun saveDisabledSeriesTrackChoice(
|
||||||
dto.seriesId?.let {
|
dto: BaseItemDto,
|
||||||
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
|
type: MediaStreamType,
|
||||||
}
|
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
|
||||||
|
|
||||||
|
suspend fun saveOnlyForcedSeriesTrackChoice(
|
||||||
|
dto: BaseItemDto,
|
||||||
|
type: MediaStreamType,
|
||||||
|
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
||||||
|
|
@ -104,21 +135,15 @@ class StreamChoiceService
|
||||||
*/
|
*/
|
||||||
suspend fun chooseAudioStream(
|
suspend fun chooseAudioStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
seriesId: UUID?,
|
item: BaseItemDto,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>?,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult? {
|
||||||
val plc =
|
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||||
plc ?: seriesId?.let {
|
|
||||||
playbackLanguageChoiceDao.get(
|
|
||||||
serverRepository.currentUser!!.rowId,
|
|
||||||
it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
return source.mediaStreams?.letNotEmpty { streams ->
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
||||||
chooseAudioStream(candidates, itemPlayback, plc, prefs)
|
chooseAudioStream(candidates, itemPlayback, stc, prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,59 +153,70 @@ class StreamChoiceService
|
||||||
fun chooseAudioStream(
|
fun chooseAudioStream(
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? =
|
): StreamChoiceResult =
|
||||||
if (itemPlayback?.audioIndexEnabled == true) {
|
if (itemPlayback?.audioIndexEnabled == true) {
|
||||||
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
|
candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
|
||||||
} else {
|
StreamChoiceReason.Item(
|
||||||
val seriesLang =
|
itemPlayback,
|
||||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
)
|
||||||
// If the user has chosen a different language for the series, prefer that
|
} else if (stc.isNotEmpty()) {
|
||||||
val audioLanguage =
|
val first = stc.first()
|
||||||
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
val result = scoreStreams(candidates, first)
|
||||||
if (audioLanguage.isNotNullOrBlank()) {
|
if (result == null) {
|
||||||
val sorted =
|
null with StreamChoiceReason.from(first)
|
||||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
} else if (result.isEmpty() && stc.size > 1) {
|
||||||
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
|
// SeriesTrackChoice did not apply to any streams, but there are more options
|
||||||
?: sorted.firstOrNull { it.language == audioLanguage }
|
chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
|
||||||
?: sorted.firstOrNull { it.isDefault }
|
} else if (result.isEmpty()) {
|
||||||
?: sorted.firstOrNull()
|
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
|
||||||
|
Timber.v("No audio STC applied")
|
||||||
|
chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull { it.isDefault }
|
// Otherwise, use the best scored stream
|
||||||
?: candidates.firstOrNull()
|
Timber.v("Using audio STC from %s", first.parentId)
|
||||||
|
result.first().stream with StreamChoiceReason.from(first, result)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
||||||
|
val stream =
|
||||||
|
if (audioLanguage.isNotNullOrBlank()) {
|
||||||
|
val sorted =
|
||||||
|
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
||||||
|
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
|
||||||
|
?: sorted.firstOrNull { it.language == audioLanguage }
|
||||||
|
?: sorted.firstOrNull { it.isDefault }
|
||||||
|
?: sorted.firstOrNull()
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isDefault }
|
||||||
|
?: candidates.firstOrNull()
|
||||||
|
}
|
||||||
|
stream with StreamChoiceReason.UserPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the subtitle stream that should play
|
* Returns the subtitle stream that should play
|
||||||
*/
|
*/
|
||||||
suspend fun chooseSubtitleStream(
|
suspend fun chooseSubtitleStream(
|
||||||
|
item: BaseItemDto,
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStream: MediaStream?,
|
audioStream: MediaStream?,
|
||||||
seriesId: UUID?,
|
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
plc: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>?,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult? =
|
||||||
val plc =
|
source.mediaStreams?.letNotEmpty { streams ->
|
||||||
plc ?: seriesId?.let {
|
|
||||||
playbackLanguageChoiceDao.get(
|
|
||||||
serverRepository.currentUser!!.rowId,
|
|
||||||
it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
audioStream?.language,
|
audioStream?.language,
|
||||||
candidates,
|
candidates,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
plc,
|
stc,
|
||||||
prefs,
|
prefs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves ONLY_FORCED to an actual subtitle track index.
|
* Resolves ONLY_FORCED to an actual subtitle track index.
|
||||||
|
|
@ -189,7 +225,7 @@ class StreamChoiceService
|
||||||
suspend fun resolveSubtitleIndex(
|
suspend fun resolveSubtitleIndex(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStreamIndex: Int?,
|
audioStreamIndex: Int?,
|
||||||
seriesId: UUID?,
|
item: BaseItemDto,
|
||||||
subtitleIndex: Int,
|
subtitleIndex: Int,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): Int? =
|
): Int? =
|
||||||
|
|
@ -203,17 +239,17 @@ class StreamChoiceService
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
userId = serverRepository.currentUser!!.rowId,
|
userId = serverRepository.currentUser!!.rowId,
|
||||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
itemId = item.id,
|
||||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||||
)
|
)
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
|
item = item,
|
||||||
source = source,
|
source = source,
|
||||||
audioStream = audioStream,
|
audioStream = audioStream,
|
||||||
seriesId = seriesId,
|
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = null,
|
stc = null,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)?.index
|
)?.stream?.index
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -223,47 +259,81 @@ class StreamChoiceService
|
||||||
audioStreamLang: String?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
stc: List<SeriesTrackChoice>,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): StreamChoiceResult {
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
|
||||||
return null
|
if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
|
||||||
}
|
return null with StreamChoiceReason.Item(itemPlayback)
|
||||||
val seriesLang =
|
} else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
val subtitleLanguage =
|
||||||
val subtitleLanguage =
|
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
// Client-side manual override: User selected "Only Forced" in player menu
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
return findForcedTrack(
|
||||||
// Client-side manual override: User selected "Only Forced" in player menu
|
candidates,
|
||||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
subtitleLanguage,
|
||||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
audioStreamLang,
|
||||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
) with StreamChoiceReason.Item(itemPlayback)
|
||||||
|
} else {
|
||||||
|
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
|
||||||
|
StreamChoiceReason.Item(itemPlayback)
|
||||||
|
}
|
||||||
|
} else if (stc.isNotEmpty()) {
|
||||||
|
val first = stc.first()
|
||||||
|
return when (first.activation) {
|
||||||
|
ActivationFlag.DISABLED -> {
|
||||||
|
null with StreamChoiceReason.from(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivationFlag.ONLY_FORCED -> {
|
||||||
|
val subtitleLanguage =
|
||||||
|
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
|
val stream =
|
||||||
|
findForcedTrack(
|
||||||
|
candidates,
|
||||||
|
subtitleLanguage,
|
||||||
|
audioStreamLang,
|
||||||
|
)
|
||||||
|
if (stream != null) {
|
||||||
|
stream with StreamChoiceReason.from(first)
|
||||||
|
} else {
|
||||||
|
handleSubtitleStreamChoiceResult(
|
||||||
|
result = emptyList(),
|
||||||
|
audioStreamLang = audioStreamLang,
|
||||||
|
candidates = candidates,
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
stc = stc,
|
||||||
|
prefs = prefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivationFlag.ACTIVATED -> {
|
||||||
|
val result =
|
||||||
|
scoreStreams(candidates, first)
|
||||||
|
?: throw IllegalStateException("scoreStreams should not return null for activated")
|
||||||
|
handleSubtitleStreamChoiceResult(
|
||||||
|
result = result,
|
||||||
|
audioStreamLang = audioStreamLang,
|
||||||
|
candidates = candidates,
|
||||||
|
itemPlayback = itemPlayback,
|
||||||
|
stc = stc,
|
||||||
|
prefs = prefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val subtitleLanguage =
|
||||||
|
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
val subtitleMode =
|
val subtitleMode =
|
||||||
when {
|
when (prefs.userPreferences?.subtitleMode) {
|
||||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
|
||||||
// User has chosen a series level subtitle language, so override their normal
|
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
|
||||||
// subtitle mode to display that language
|
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
|
||||||
SubtitlePlaybackMode.ALWAYS
|
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
|
||||||
}
|
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
|
||||||
|
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
|
||||||
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
|
null -> SubtitlePlaybackMode.DEFAULT
|
||||||
// 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
|
} ?: SubtitlePlaybackMode.DEFAULT
|
||||||
val candidates =
|
val candidates =
|
||||||
candidates
|
candidates
|
||||||
|
|
@ -277,73 +347,104 @@ class StreamChoiceService
|
||||||
}.thenByDescending { it.isForced && it.language.isUnknown }
|
}.thenByDescending { it.isForced && it.language.isUnknown }
|
||||||
.thenByDescending { it.isForced },
|
.thenByDescending { it.isForced },
|
||||||
)
|
)
|
||||||
return when (subtitleMode) {
|
val stream =
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
when (subtitleMode) {
|
||||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
candidates.firstOrNull {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
// Prefer non-forced first
|
candidates.firstOrNull {
|
||||||
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
// Prefer non-forced first
|
||||||
} ?: candidates.firstOrNull {
|
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||||
it.language.equalsLangOrUnknown(subtitleLanguage)
|
} ?: candidates.firstOrNull {
|
||||||
}
|
it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||||
} else {
|
}
|
||||||
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.ONLY_FORCED -> {
|
|
||||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
|
||||||
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
|
|
||||||
} else {
|
|
||||||
candidates.firstOrNull { it.isForced }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitlePlaybackMode.SMART -> {
|
|
||||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
|
||||||
val audioLanguage = userConfig?.audioLanguagePreference
|
|
||||||
if (
|
|
||||||
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
|
|
||||||
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
|
|
||||||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
|
|
||||||
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
|
|
||||||
) {
|
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
|
||||||
?: candidates.firstOrNull { it.language.isUnknown }
|
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, show forced subtitles in preferred lang
|
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
|
||||||
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
|
||||||
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
candidates.firstOrNull { it.isDefault }
|
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED -> {
|
||||||
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isForced }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.SMART -> {
|
||||||
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
|
val audioLanguage = userConfig?.audioLanguagePreference
|
||||||
|
if (
|
||||||
|
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
|
||||||
|
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
|
||||||
|
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
|
||||||
|
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
|
||||||
|
) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown }
|
||||||
|
} else {
|
||||||
|
// Otherwise, show forced subtitles in preferred lang
|
||||||
|
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isDefault }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.DEFAULT -> {
|
||||||
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
|
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
|
||||||
|
?: candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlePlaybackMode.NONE -> {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return stream with StreamChoiceReason.UserPreferences
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the track is forced (via metadata flag or title patterns). */
|
/**
|
||||||
private fun isForcedOrSigns(track: MediaStream): Boolean {
|
* Recursively calls [chooseSubtitleStream] based on [result] and [stc]
|
||||||
if (track.isForced) return true
|
*/
|
||||||
val title = track.title ?: track.displayTitle ?: return false
|
private fun handleSubtitleStreamChoiceResult(
|
||||||
return title.contains("forced", ignoreCase = true) ||
|
result: List<ScoredMediaStream>,
|
||||||
title.contains("signs", ignoreCase = true) ||
|
audioStreamLang: String?,
|
||||||
title.contains("songs", ignoreCase = true)
|
candidates: List<MediaStream>,
|
||||||
|
itemPlayback: ItemPlayback?,
|
||||||
|
stc: List<SeriesTrackChoice>,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
): StreamChoiceResult {
|
||||||
|
val first = stc.first()
|
||||||
|
return if (result.isEmpty() && stc.size > 1) {
|
||||||
|
// SeriesTrackChoice did not apply to any streams, but there are more options
|
||||||
|
chooseSubtitleStream(
|
||||||
|
audioStreamLang,
|
||||||
|
candidates,
|
||||||
|
itemPlayback,
|
||||||
|
stc.subList(1, stc.size),
|
||||||
|
prefs,
|
||||||
|
)
|
||||||
|
} else if (result.isEmpty()) {
|
||||||
|
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
|
||||||
|
Timber.v("No subtitle STC applied")
|
||||||
|
chooseSubtitleStream(
|
||||||
|
audioStreamLang,
|
||||||
|
candidates,
|
||||||
|
itemPlayback,
|
||||||
|
emptyList(),
|
||||||
|
prefs,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Otherwise, use the best scored stream
|
||||||
|
Timber.v("Using subtitle STC from %s", first.parentId)
|
||||||
|
result.first().stream with StreamChoiceReason.from(first, result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
|
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
|
||||||
|
|
@ -367,19 +468,92 @@ class StreamChoiceService
|
||||||
// 3. Unknown language forced track
|
// 3. Unknown language forced track
|
||||||
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
|
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getSeriesTrackChoices(
|
||||||
|
seriesId: UUID?,
|
||||||
|
seasonId: UUID?,
|
||||||
|
type: SeriesTrackChoiceType,
|
||||||
|
): List<SeriesTrackChoice> {
|
||||||
|
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<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type)
|
||||||
|
|
||||||
|
suspend fun getSeriesTrackChoices(
|
||||||
|
item: BaseItemDto,
|
||||||
|
type: MediaStreamType,
|
||||||
|
): List<SeriesTrackChoice> =
|
||||||
|
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
|
private val String?.isUnknown: Boolean
|
||||||
get() =
|
get() =
|
||||||
this == null ||
|
this.isNullOrBlank() ||
|
||||||
this.equals("unknown", true) ||
|
this.equals("unknown", true) ||
|
||||||
this.equals("und", true) ||
|
this.equals("und", true) ||
|
||||||
this.equals("undetermined", true) ||
|
this.equals("undetermined", true) ||
|
||||||
this.equals("mul", true) ||
|
this.equals("mul", true) ||
|
||||||
this.equals("zxx", 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?.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
|
* Based on the user's preferences, get their preferred language for audio or subtitles
|
||||||
*
|
*
|
||||||
|
|
@ -412,3 +586,153 @@ fun getPreferredLanguage(
|
||||||
else -> pref
|
else -> pref
|
||||||
}?.takeIf { it.isNotNullOrBlank() }
|
}?.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<MediaStream>,
|
||||||
|
choice: SeriesTrackChoice,
|
||||||
|
): List<ScoredMediaStream>? {
|
||||||
|
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<ScoredMediaStream> 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<ScoredMediaStream>,
|
||||||
|
) : StreamChoiceReason {
|
||||||
|
override val description: StringProvider =
|
||||||
|
ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Season(
|
||||||
|
val stc: SeriesTrackChoice,
|
||||||
|
override val scores: List<ScoredMediaStream>,
|
||||||
|
) : 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<ScoredMediaStream> = 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.github.damontecres.wholphin.data.RestoredSession
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -63,12 +64,8 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||||
var currentUser = serverRepository.current.value
|
val result = serverRepository.restoreLastSession()
|
||||||
if (currentUser == null) {
|
if (result !is RestoredSession.Success) {
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
currentUser = serverRepository.current.value
|
|
||||||
}
|
|
||||||
if (currentUser == null) {
|
|
||||||
Timber.w("No user found during run")
|
Timber.w("No user found during run")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
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
|
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
|
||||||
|
|
@ -36,6 +37,7 @@ class UserSwitchListener
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
private val seerrApi: SeerrApi,
|
private val seerrApi: SeerrApi,
|
||||||
private val homeSettingsService: HomeSettingsService,
|
private val homeSettingsService: HomeSettingsService,
|
||||||
|
private val serverPluginApi: ServerPluginApi,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
context as AppCompatActivity
|
context as AppCompatActivity
|
||||||
|
|
@ -65,49 +67,78 @@ class UserSwitchListener
|
||||||
AppCompatDelegate.setApplicationLocales(localeList)
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for home settings
|
// Check if plugin is installed, then for home settings & seerr settings
|
||||||
launchIO {
|
launchIO {
|
||||||
homeSettingsService.loadCurrentSettings(user.id)
|
val serverPluginInstalled =
|
||||||
}
|
try {
|
||||||
if (BuildConfig.DISCOVER_ENABLED) {
|
serverPluginApi.checkInstalled()
|
||||||
// Check for seerr server
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error checking for server plugin")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Timber.i("Server plugin installed: %s", serverPluginInstalled)
|
||||||
|
serverRepository.serverPluginInstalled.value = serverPluginInstalled
|
||||||
|
|
||||||
launchIO {
|
launchIO {
|
||||||
seerrServerDao
|
// Check for home settings
|
||||||
.getUsersByJellyfinUser(user.rowId)
|
homeSettingsService.loadCurrentSettings(user)
|
||||||
.lastOrNull()
|
}
|
||||||
?.let { seerrUser ->
|
|
||||||
val server =
|
if (BuildConfig.DISCOVER_ENABLED) {
|
||||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
// Check for seerr server
|
||||||
if (server != null) {
|
launchIO {
|
||||||
Timber.i("Found a seerr user & server")
|
if (serverPluginInstalled) {
|
||||||
try {
|
serverPluginApi.fetchSeerrSettings()?.let {
|
||||||
seerrApi.update(server.url, seerrUser.credential)
|
Timber.d("Trying seerr auto login")
|
||||||
val userConfig =
|
val result = seerrServerRepository.usePluginProvidedServer(it)
|
||||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
if (result) {
|
||||||
seerrLogin(
|
// Successfully used server plugin provided seerr
|
||||||
seerrApi.api,
|
Timber.i("Seerr auto login succeeded")
|
||||||
seerrUser.authMethod,
|
return@launchIO
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
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.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.services.SeerrApi
|
import com.github.damontecres.wholphin.services.SeerrApi
|
||||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||||
|
|
@ -188,6 +190,12 @@ object AppModule {
|
||||||
fun seerrApi(
|
fun seerrApi(
|
||||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
) = SeerrApi(okHttpClient)
|
) = SeerrApi(okHttpClient)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun mostRecentServerProvider(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
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.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
|
|
@ -79,6 +80,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.github.damontecres.wholphin.MainActivity
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
import com.github.damontecres.wholphin.R
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
|
@ -70,12 +71,8 @@ class TvProviderWorker
|
||||||
|
|
||||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||||
// Not active
|
// Not active
|
||||||
var currentUser = serverRepository.current.value
|
val result = serverRepository.restoreLastSession()
|
||||||
if (currentUser == null) {
|
if (result !is RestoredSession.Success) {
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
currentUser = serverRepository.current.value
|
|
||||||
}
|
|
||||||
if (currentUser == null) {
|
|
||||||
Timber.w("No user found during run")
|
Timber.w("No user found during run")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
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.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
|
@ -205,6 +208,13 @@ fun formatBytes(
|
||||||
|
|
||||||
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
|
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
|
@get:StringRes
|
||||||
val MediaSegmentType.stringRes: Int
|
val MediaSegmentType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -300,3 +310,55 @@ fun Resources.formatDuration(duration: Duration): String =
|
||||||
else -> getString(R.string.duration_seconds, seconds)
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
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 com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -53,19 +52,18 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderGrid(
|
fun CollectionFolderViewScope.CollectionFolderGrid(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
collectionType: CollectionType?,
|
|
||||||
focusedItem: BaseItem?,
|
focusedItem: BaseItem?,
|
||||||
items: List<BaseItem?>,
|
items: List<BaseItem?>,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
viewOptions: ViewOptions,
|
|
||||||
onClickPlay: (Int, BaseItem) -> Unit,
|
onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
initialPosition: Int,
|
initialPosition: Int,
|
||||||
gridFocusRequester: FocusRequester,
|
gridFocusRequester: FocusRequester,
|
||||||
|
cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
@ -99,18 +97,8 @@ fun CollectionFolderGrid(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
initialPosition = initialPosition,
|
initialPosition = initialPosition,
|
||||||
positionCallback = positionCallback,
|
positionCallback = positionCallback,
|
||||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
cardContent = { details ->
|
||||||
GridCard(
|
cardContent.invoke(this@CollectionFolderGrid, details)
|
||||||
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,
|
columns = viewOptions.columns,
|
||||||
spacing = viewOptions.spacing.dp,
|
spacing = viewOptions.spacing.dp,
|
||||||
|
|
@ -210,10 +198,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
|
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.HOMEVIDEOS -> {
|
CollectionType.PHOTOS,
|
||||||
listOf(BaseItemKind.VIDEO)
|
CollectionType.HOMEVIDEOS,
|
||||||
listOf(BaseItemKind.PHOTO)
|
-> {
|
||||||
listOf(BaseItemKind.PHOTO_ALBUM)
|
listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.MUSIC -> {
|
CollectionType.MUSIC -> {
|
||||||
|
|
@ -236,10 +224,6 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
listOf(BaseItemKind.PLAYLIST)
|
listOf(BaseItemKind.PLAYLIST)
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.PHOTOS -> {
|
|
||||||
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
|
||||||
}
|
|
||||||
|
|
||||||
CollectionType.LIVETV -> {
|
CollectionType.LIVETV -> {
|
||||||
listOf(
|
listOf(
|
||||||
BaseItemKind.TV_CHANNEL,
|
BaseItemKind.TV_CHANNEL,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
|
||||||
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
|
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
|
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.enableMarquee
|
||||||
import com.github.damontecres.wholphin.ui.formatDuration
|
import com.github.damontecres.wholphin.ui.formatDuration
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
|
@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderList(
|
fun CollectionFolderViewScope.CollectionFolderList(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
collectionType: CollectionType?,
|
collectionType: CollectionType?,
|
||||||
focusedItem: BaseItem?,
|
focusedItem: BaseItem?,
|
||||||
|
|
@ -84,10 +86,10 @@ fun CollectionFolderList(
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
viewOptions: ViewOptions,
|
|
||||||
onClickPlay: (Int, BaseItem) -> Unit,
|
onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
initialPosition: Int,
|
initialPosition: Int,
|
||||||
gridFocusRequester: FocusRequester,
|
gridFocusRequester: FocusRequester,
|
||||||
|
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
@ -153,10 +155,8 @@ fun CollectionFolderList(
|
||||||
).padding(8.dp),
|
).padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CollectionFolderContent(
|
this@CollectionFolderList.CollectionFolderListContent(
|
||||||
items = items,
|
items = items,
|
||||||
viewOptions = viewOptions,
|
|
||||||
collectionType = collectionType,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
position = position,
|
position = position,
|
||||||
positionFocusRequester = positionFocusRequester,
|
positionFocusRequester = positionFocusRequester,
|
||||||
|
|
@ -167,6 +167,7 @@ fun CollectionFolderList(
|
||||||
},
|
},
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = onLongClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
|
itemContent = itemContent,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
|
@ -266,44 +267,43 @@ fun CollectionFolderListDetails(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderContent(
|
fun CollectionFolderViewScope.CollectionFolderListContent(
|
||||||
items: List<BaseItem?>,
|
items: List<BaseItem?>,
|
||||||
viewOptions: ViewOptions,
|
|
||||||
collectionType: CollectionType?,
|
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
position: Int,
|
position: Int,
|
||||||
positionFocusRequester: FocusRequester,
|
positionFocusRequester: FocusRequester,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
onPositionChange: (Int) -> Unit,
|
onPositionChange: (Int) -> Unit,
|
||||||
|
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val currentPosition by rememberUpdatedState(position)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
|
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
|
||||||
modifier = modifier.focusRestorer(positionFocusRequester),
|
modifier = modifier.focusRestorer(positionFocusRequester),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { index, item ->
|
itemsIndexed(items) { index, item ->
|
||||||
val onClick: () -> Unit =
|
val details =
|
||||||
remember(index, item) {
|
remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
|
||||||
{ item?.let { onClickItem.invoke(index, item) } }
|
GridItemDetails(
|
||||||
|
item = item,
|
||||||
|
index = index,
|
||||||
|
onClick = { item?.let { onClickItem.invoke(index, item) } },
|
||||||
|
onLongClick = { item?.let { onLongClickItem.invoke(index, item) } },
|
||||||
|
widthPx = -1,
|
||||||
|
mod =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onPositionChange.invoke(index)
|
||||||
|
}.ifElse(
|
||||||
|
index == currentPosition,
|
||||||
|
Modifier.focusRequester(positionFocusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val onLongClick: () -> Unit =
|
itemContent.invoke(this@CollectionFolderListContent, details)
|
||||||
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,9 +522,12 @@ fun ListItemAlbum(
|
||||||
}
|
}
|
||||||
val supportingContent =
|
val supportingContent =
|
||||||
remember(item, dense) {
|
remember(item, dense) {
|
||||||
if (!dense && item?.subtitle != null) {
|
// Album artist is already shown as the overline
|
||||||
|
val subtitle =
|
||||||
|
if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle
|
||||||
|
if (!dense && subtitle != null) {
|
||||||
@Composable {
|
@Composable {
|
||||||
Text(item.subtitle ?: "")
|
Text(subtitle)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -54,20 +54,25 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
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.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.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
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.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
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.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.BlockingList
|
import com.github.damontecres.wholphin.util.BlockingList
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
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.GetArtistsHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
|
|
@ -89,12 +94,14 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
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.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
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.GetArtistsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
|
|
@ -146,6 +153,9 @@ class CollectionFolderViewModel
|
||||||
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
|
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
|
||||||
val state: StateFlow<CollectionFolderState> = _state
|
val state: StateFlow<CollectionFolderState> = _state
|
||||||
|
|
||||||
|
private val artistSource: ArtistSource
|
||||||
|
get() = state.value.viewOptions.artistSource
|
||||||
|
|
||||||
var position: Int
|
var position: Int
|
||||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|
@ -168,15 +178,25 @@ class CollectionFolderViewModel
|
||||||
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
|
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
|
||||||
libraryDisplayInfoDao.getItem(user, id)
|
libraryDisplayInfoDao.getItem(user, id)
|
||||||
}
|
}
|
||||||
|
val ancestorDisplayInfo =
|
||||||
|
if (libraryDisplayInfo?.viewOptions == null && item != null) {
|
||||||
|
getAncestorDisplayInfo(item)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
viewOptions =
|
||||||
|
libraryDisplayInfo?.viewOptions
|
||||||
|
?: ancestorDisplayInfo?.viewOptions
|
||||||
|
?: defaultViewOptions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortAndDirection =
|
val sortAndDirection =
|
||||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||||
libraryDisplayInfo?.sortAndDirection
|
libraryDisplayInfo?.sortAndDirection
|
||||||
|
?: ancestorDisplayInfo?.sortAndDirection
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
||||||
|
|
@ -232,6 +252,25 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a sub-folder, returns the saved display info of the closest ancestor that has one
|
||||||
|
*/
|
||||||
|
private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? {
|
||||||
|
if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val user = serverRepository.currentUser ?: return null
|
||||||
|
return try {
|
||||||
|
api.libraryApi
|
||||||
|
.getAncestors(item.id, user.id)
|
||||||
|
.content
|
||||||
|
.firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) }
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error getting ancestors for %s", item.id)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveLibraryDisplayInfo(
|
private fun saveLibraryDisplayInfo(
|
||||||
newFilter: GetItemsFilter = state.value.filter,
|
newFilter: GetItemsFilter = state.value.filter,
|
||||||
newSort: SortAndDirection = state.value.sortAndDirection,
|
newSort: SortAndDirection = state.value.sortAndDirection,
|
||||||
|
|
@ -257,7 +296,17 @@ class CollectionFolderViewModel
|
||||||
|
|
||||||
override fun saveViewOptions(viewOptions: ViewOptions) {
|
override fun saveViewOptions(viewOptions: ViewOptions) {
|
||||||
position = 0
|
position = 0
|
||||||
|
val artistSourceChanged = viewOptions.artistSource != artistSource
|
||||||
_state.update { it.copy(viewOptions = viewOptions) }
|
_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) {
|
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||||
if (!viewOptions.showBackdrop) {
|
if (!viewOptions.showBackdrop) {
|
||||||
|
|
@ -269,11 +318,19 @@ class CollectionFolderViewModel
|
||||||
override fun onClickRandom() {
|
override fun onClickRandom() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
val random =
|
val state = state.value
|
||||||
(state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
|
val list = state.items.successValue as? BlockingList<BaseItem?>
|
||||||
Timber.d("Got random item: %s", random?.id)
|
if (list != null) {
|
||||||
random?.destination()?.let {
|
goToRandom(
|
||||||
navigateTo(random.destination())
|
navigateTo = ::navigateTo,
|
||||||
|
parentId = itemId,
|
||||||
|
filter = state.filter,
|
||||||
|
sortAndDirection = state.sortAndDirection,
|
||||||
|
recursive = recursive,
|
||||||
|
list = list,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w("state.value.items is not successful: %s", state.items)
|
||||||
}
|
}
|
||||||
} catch (ex: CancellationException) {
|
} catch (ex: CancellationException) {
|
||||||
throw ex
|
throw ex
|
||||||
|
|
@ -399,13 +456,27 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
GetItemsFilterOverride.ARTIST -> {
|
GetItemsFilterOverride.ARTIST -> {
|
||||||
ApiRequestPager(
|
when (artistSource) {
|
||||||
api,
|
ArtistSource.ARTIST -> {
|
||||||
createGetArtistsRequest(filter),
|
ApiRequestPager(
|
||||||
GetArtistsHandler,
|
api,
|
||||||
viewModelScope,
|
createGetArtistsRequest(filter),
|
||||||
useSeriesForPrimary = useSeriesForPrimary,
|
GetArtistsHandler,
|
||||||
)
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtistSource.ALBUM_ARTIST -> {
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
createGetAlbumArtistsRequest(filter),
|
||||||
|
GetAlbumArtistsHandler,
|
||||||
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,6 +495,17 @@ class CollectionFolderViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest {
|
||||||
|
val item = state.value.item.successValue
|
||||||
|
return filter.applyTo(
|
||||||
|
GetAlbumArtistsRequest(
|
||||||
|
parentId = item?.id,
|
||||||
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||||
|
fields = SlimItemFields,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createGetItemsRequest(
|
private fun createGetItemsRequest(
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
|
|
@ -495,18 +577,37 @@ class CollectionFolderViewModel
|
||||||
val filter = state.value.filter
|
val filter = state.value.filter
|
||||||
when (filter.override) {
|
when (filter.override) {
|
||||||
GetItemsFilterOverride.ARTIST -> {
|
GetItemsFilterOverride.ARTIST -> {
|
||||||
GetArtistsHandler.countMatching(
|
when (artistSource) {
|
||||||
api = api,
|
ArtistSource.ARTIST -> {
|
||||||
request =
|
GetArtistsHandler.countMatching(
|
||||||
createGetArtistsRequest(filter).copy(
|
api = api,
|
||||||
enableImageTypes = null,
|
request =
|
||||||
fields = null,
|
createGetArtistsRequest(filter).copy(
|
||||||
nameLessThan = letter.toString(),
|
enableImageTypes = null,
|
||||||
limit = 0,
|
fields = null,
|
||||||
enableTotalRecordCount = true,
|
nameLessThan = letter.toString(),
|
||||||
enableUserData = false,
|
limit = 0,
|
||||||
),
|
enableTotalRecordCount = true,
|
||||||
)
|
enableUserData = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtistSource.ALBUM_ARTIST -> {
|
||||||
|
GetAlbumArtistsHandler.countMatching(
|
||||||
|
api = api,
|
||||||
|
request =
|
||||||
|
createGetAlbumArtistsRequest(filter).copy(
|
||||||
|
enableImageTypes = null,
|
||||||
|
fields = null,
|
||||||
|
nameLessThan = letter.toString(),
|
||||||
|
limit = 0,
|
||||||
|
enableTotalRecordCount = true,
|
||||||
|
enableUserData = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
|
// GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
|
||||||
|
|
@ -813,6 +914,33 @@ fun CollectionFolderViewContent(
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||||
focusRequesterOnEmpty: FocusRequester? = null,
|
focusRequesterOnEmpty: FocusRequester? = null,
|
||||||
|
gridCardContent: @Composable CollectionFolderViewScope.(
|
||||||
|
GridItemDetails<BaseItem>,
|
||||||
|
) -> 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<BaseItem>,
|
||||||
|
) -> 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)
|
var position by rememberInt(savedPosition)
|
||||||
|
|
||||||
|
|
@ -975,11 +1103,22 @@ fun CollectionFolderViewContent(
|
||||||
focusRequester?.tryRequestFocus()
|
focusRequester?.tryRequestFocus()
|
||||||
clickedRandom = false
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (state.viewOptions.type == ViewOptionsType.GRID) {
|
if (state.viewOptions.type == ViewOptionsType.GRID) {
|
||||||
CollectionFolderGrid(
|
scope.CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
collectionType = item?.data?.collectionType,
|
|
||||||
initialPosition = savedPosition,
|
initialPosition = savedPosition,
|
||||||
items = pager.data,
|
items = pager.data,
|
||||||
sortAndDirection = state.sortAndDirection,
|
sortAndDirection = state.sortAndDirection,
|
||||||
|
|
@ -993,12 +1132,12 @@ fun CollectionFolderViewContent(
|
||||||
positionCallback?.invoke(columns, pos)
|
positionCallback?.invoke(columns, pos)
|
||||||
},
|
},
|
||||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = state.viewOptions,
|
|
||||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||||
focusedItem = focusedItem,
|
focusedItem = focusedItem,
|
||||||
|
cardContent = gridCardContent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CollectionFolderList(
|
scope.CollectionFolderList(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
collectionType = item?.data?.collectionType,
|
collectionType = item?.data?.collectionType,
|
||||||
initialPosition = savedPosition,
|
initialPosition = savedPosition,
|
||||||
|
|
@ -1014,9 +1153,9 @@ fun CollectionFolderViewContent(
|
||||||
positionCallback?.invoke(columns, pos)
|
positionCallback?.invoke(columns, pos)
|
||||||
},
|
},
|
||||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = state.viewOptions,
|
|
||||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||||
focusedItem = focusedItem,
|
focusedItem = focusedItem,
|
||||||
|
itemContent = listItemContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
|
@ -1047,6 +1186,7 @@ fun CollectionFolderViewContent(
|
||||||
ViewOptionsDialog(
|
ViewOptionsDialog(
|
||||||
viewOptions = state.viewOptions,
|
viewOptions = state.viewOptions,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
|
showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showViewOptions = false
|
showViewOptions = false
|
||||||
viewActions.saveViewOptions(state.viewOptions)
|
viewActions.saveViewOptions(state.viewOptions)
|
||||||
|
|
@ -1055,3 +1195,13 @@ fun CollectionFolderViewContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
|
||||||
|
*/
|
||||||
|
interface CollectionFolderViewScope {
|
||||||
|
val viewOptions: ViewOptions
|
||||||
|
val collectionType: CollectionType?
|
||||||
|
val sortAndDirection: SortAndDirection
|
||||||
|
val collectionSize: Int
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,12 @@ fun ContextMenu(
|
||||||
favorite = item.favorite,
|
favorite = item.favorite,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
canClearChosenStreams =
|
||||||
|
chosenStreams.let {
|
||||||
|
it?.itemPlayback != null ||
|
||||||
|
it?.audioStc?.isNotEmpty() == true ||
|
||||||
|
it?.subtitleStc?.isNotEmpty() == true
|
||||||
|
},
|
||||||
contextMenu = contextMenu,
|
contextMenu = contextMenu,
|
||||||
actions = actions,
|
actions = actions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
|
|
@ -235,11 +240,11 @@ fun ContextMenu(
|
||||||
resources = resources,
|
resources = resources,
|
||||||
streams = source.mediaStreams.orEmpty(),
|
streams = source.mediaStreams.orEmpty(),
|
||||||
type = type,
|
type = type,
|
||||||
currentIndex =
|
streamChoice =
|
||||||
if (type == MediaStreamType.AUDIO) {
|
if (type == MediaStreamType.AUDIO) {
|
||||||
chosenStreams?.audioStream?.index
|
chosenStreams?.audioStream
|
||||||
} else {
|
} else {
|
||||||
chosenStreams?.subtitleStream?.index
|
chosenStreams?.subtitleStream
|
||||||
},
|
},
|
||||||
onClick = { trackIndex ->
|
onClick = { trackIndex ->
|
||||||
actions.onChooseTracks.invoke(
|
actions.onChooseTracks.invoke(
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
|
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.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -230,28 +231,35 @@ fun DialogPopup(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
properties = properties,
|
properties = properties,
|
||||||
) {
|
) {
|
||||||
|
val elevatedContainerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||||
DialogPopupContent(
|
DialogPopupContent(
|
||||||
title = title,
|
title = title,
|
||||||
dialogItems = dialogItems,
|
dialogItems = dialogItems,
|
||||||
waiting = waiting,
|
waiting = waiting,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
dismissOnClick = dismissOnClick,
|
dismissOnClick = dismissOnClick,
|
||||||
elevation = elevation,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.onKeyEvent { event ->
|
Modifier
|
||||||
val code = event.nativeKeyEvent.keyCode
|
.onKeyEvent { event ->
|
||||||
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
|
val code = event.nativeKeyEvent.keyCode
|
||||||
code in
|
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
|
||||||
setOf(
|
code in
|
||||||
KeyEvent.KEYCODE_ENTER,
|
setOf(
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
KeyEvent.KEYCODE_ENTER,
|
||||||
KeyEvent.KEYCODE_NUMPAD_ENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
)
|
KeyEvent.KEYCODE_NUMPAD_ENTER,
|
||||||
) {
|
)
|
||||||
waiting = false
|
) {
|
||||||
}
|
waiting = false
|
||||||
false
|
}
|
||||||
},
|
false
|
||||||
|
}.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
|
||||||
|
.graphicsLayer {
|
||||||
|
this.clip = true
|
||||||
|
this.shape = RoundedCornerShape(28.0.dp)
|
||||||
|
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||||
|
.padding(PaddingValues(24.dp)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,20 +273,10 @@ fun DialogPopupContent(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
dismissOnClick: Boolean = true,
|
dismissOnClick: Boolean = true,
|
||||||
elevation: Dp = 8.dp,
|
|
||||||
) {
|
) {
|
||||||
val elevatedContainerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
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(
|
||||||
text = title,
|
text = title,
|
||||||
|
|
@ -452,12 +450,14 @@ fun ConfirmDialog(
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
elevation: Dp = 1.dp,
|
elevation: Dp = 1.dp,
|
||||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||||
|
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||||
) = BasicDialog(
|
) = BasicDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
properties = properties,
|
properties = properties,
|
||||||
elevation = elevation,
|
elevation = elevation,
|
||||||
content = {
|
content = {
|
||||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -472,6 +472,8 @@ fun ConfirmDialogContent(
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||||
|
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -502,11 +504,11 @@ fun ConfirmDialogContent(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = R.string.cancel,
|
stringRes = cancelStringRes,
|
||||||
onClick = onCancel,
|
onClick = onCancel,
|
||||||
)
|
)
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = R.string.confirm,
|
stringRes = confirmStringRes,
|
||||||
onClick = onConfirm,
|
onClick = onConfirm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -651,12 +653,13 @@ fun resourceFor(type: MediaStreamType): Int =
|
||||||
fun chooseStream(
|
fun chooseStream(
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
streams: List<MediaStream>,
|
streams: List<MediaStream>,
|
||||||
currentIndex: Int?,
|
streamChoice: StreamChoiceResult?,
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
preferredSubtitleLanguage: String?,
|
preferredSubtitleLanguage: String?,
|
||||||
onClick: (Int) -> Unit,
|
onClick: (Int) -> Unit,
|
||||||
): DialogParams {
|
): DialogParams {
|
||||||
val filteredStreams = streams.filter { it.type == type }
|
val filteredStreams = streams.filter { it.type == type }
|
||||||
|
val currentIndex = streamChoice?.stream?.index
|
||||||
return DialogParams(
|
return DialogParams(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
|
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
|
||||||
|
|
@ -674,6 +677,16 @@ fun chooseStream(
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
},
|
},
|
||||||
|
trailingContent =
|
||||||
|
if (currentIndex == null && streamChoice != null) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = streamChoice.reason.description.getString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -719,6 +732,16 @@ fun chooseStream(
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
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) },
|
onClick = { onClick.invoke(stream.index) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) =
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RecommendedRow(
|
RecommendedRow(
|
||||||
title = R.string.top_unwatched,
|
title = R.string.top_rated,
|
||||||
handler = GetItemsRequestHandler,
|
handler = GetItemsRequestHandler,
|
||||||
request =
|
request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) =
|
||||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
isPlayed = false,
|
|
||||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ class StudioViewModel
|
||||||
itemId = it.id,
|
itemId = it.id,
|
||||||
imageType = ImageType.THUMB,
|
imageType = ImageType.THUMB,
|
||||||
fillWidth = cardWidthPx,
|
fillWidth = cardWidthPx,
|
||||||
|
tag = it.imageTags?.get(ImageType.THUMB),
|
||||||
)
|
)
|
||||||
Studio(
|
Studio(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? {
|
||||||
item.data.seriesId != null &&
|
item.data.seriesId != null &&
|
||||||
item.data.parentLogoImageTag != null
|
item.data.parentLogoImageTag != null
|
||||||
) {
|
) {
|
||||||
imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = item.data.seriesId!!,
|
||||||
|
imageType = ImageType.LOGO,
|
||||||
|
tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
|
||||||
|
)
|
||||||
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
|
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
|
||||||
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ fun VideoStreamDetails(
|
||||||
) = VideoStreamDetails(
|
) = VideoStreamDetails(
|
||||||
chosenStreams?.source,
|
chosenStreams?.source,
|
||||||
chosenStreams?.videoStream,
|
chosenStreams?.videoStream,
|
||||||
chosenStreams?.audioStream,
|
chosenStreams?.audioStream?.stream,
|
||||||
chosenStreams?.subtitleStream,
|
chosenStreams?.subtitleStream?.stream,
|
||||||
numberOfVersions,
|
numberOfVersions,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ fun ViewOptionsDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onViewOptionsChange: (ViewOptions) -> Unit,
|
onViewOptionsChange: (ViewOptions) -> Unit,
|
||||||
defaultViewOptions: ViewOptions = ViewOptions(),
|
defaultViewOptions: ViewOptions = ViewOptions(),
|
||||||
|
showArtistSource: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
|
@ -72,11 +73,11 @@ fun ViewOptionsDialog(
|
||||||
window.setDimAmount(0f)
|
window.setDimAmount(0f)
|
||||||
}
|
}
|
||||||
val options =
|
val options =
|
||||||
remember(viewOptions.type) {
|
remember(viewOptions.type, showArtistSource) {
|
||||||
when (viewOptions.type) {
|
when (viewOptions.type) {
|
||||||
ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS
|
ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource)
|
||||||
ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS
|
ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource)
|
||||||
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
|
ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -130,6 +131,12 @@ fun ViewOptionsDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which Jellyfin artists list backs the Music > Artists tab */
|
||||||
|
enum class ArtistSource {
|
||||||
|
ARTIST,
|
||||||
|
ALBUM_ARTIST,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the customizable view changes from the user
|
* Stores the customizable view changes from the user
|
||||||
*
|
*
|
||||||
|
|
@ -146,6 +153,7 @@ data class ViewOptions(
|
||||||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||||
val showTitles: Boolean = true,
|
val showTitles: Boolean = true,
|
||||||
val type: ViewOptionsType = ViewOptionsType.GRID,
|
val type: ViewOptionsType = ViewOptionsType.GRID,
|
||||||
|
val extras: ViewOptionsExtras = ViewOptionsExtras.None,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val ViewOptionsColumns =
|
val ViewOptionsColumns =
|
||||||
|
|
@ -250,12 +258,34 @@ data class ViewOptions(
|
||||||
valueToIndex = { it.ordinal },
|
valueToIndex = { it.ordinal },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ViewOptionsArtistSource =
|
||||||
|
AppChoicePreference<ViewOptions, ArtistSource>(
|
||||||
|
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 =
|
val ViewOptionsReset =
|
||||||
AppClickablePreference<ViewOptions>(
|
AppClickablePreference<ViewOptions>(
|
||||||
title = R.string.reset,
|
title = R.string.reset,
|
||||||
)
|
)
|
||||||
|
|
||||||
val GRID_OPTIONS =
|
/**
|
||||||
|
* Get the options for a grid [ViewOptions]
|
||||||
|
*/
|
||||||
|
fun buildGridOptions(showArtistSource: Boolean) =
|
||||||
|
buildList {
|
||||||
|
if (showArtistSource) add(ViewOptionsArtistSource)
|
||||||
|
addAll(GRID_OPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val GRID_OPTIONS =
|
||||||
listOf(
|
listOf(
|
||||||
ViewOptionsTypePref,
|
ViewOptionsTypePref,
|
||||||
ViewOptionsImageType,
|
ViewOptionsImageType,
|
||||||
|
|
@ -269,7 +299,16 @@ data class ViewOptions(
|
||||||
ViewOptionsReset,
|
ViewOptionsReset,
|
||||||
)
|
)
|
||||||
|
|
||||||
val LIST_OPTIONS =
|
/**
|
||||||
|
* Get the options for a list [ViewOptions]
|
||||||
|
*/
|
||||||
|
fun buildListOptions(showArtistSource: Boolean) =
|
||||||
|
buildList {
|
||||||
|
if (showArtistSource) add(ViewOptionsArtistSource)
|
||||||
|
addAll(LIST_OPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LIST_OPTIONS =
|
||||||
listOf(
|
listOf(
|
||||||
ViewOptionsTypePref,
|
ViewOptionsTypePref,
|
||||||
ViewOptionsDetailHeader,
|
ViewOptionsDetailHeader,
|
||||||
|
|
@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions
|
||||||
|
|
||||||
else -> ViewOptionsPoster
|
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
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,16 @@ import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
|
import android.view.Display.HdrCapabilities
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
@ -40,9 +46,11 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -56,9 +64,11 @@ import kotlinx.coroutines.withContext
|
||||||
import org.acra.util.versionCodeLong
|
import org.acra.util.versionCodeLong
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -68,17 +78,18 @@ class DebugViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
val itemPlaybackDao: ItemPlaybackDao,
|
||||||
|
val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||||
val clientInfo: ClientInfo,
|
val clientInfo: ClientInfo,
|
||||||
val deviceInfo: DeviceInfo,
|
val deviceInfo: DeviceInfo,
|
||||||
|
private val deviceProfileService: DeviceProfileService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val state = MutableStateFlow(DebugState())
|
val state = MutableStateFlow(DebugState())
|
||||||
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
|
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
|
||||||
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
|
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
|
||||||
|
|
||||||
val supportedModes by lazy {
|
val display: Display by lazy {
|
||||||
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||||
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
display.supportedModes.orEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val av1Included by lazy {
|
val av1Included by lazy {
|
||||||
|
|
@ -159,7 +170,7 @@ class DebugViewModel
|
||||||
state.update {
|
state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
deviceInfo = deviceInfoList,
|
deviceInfo = deviceInfoList,
|
||||||
displayModes = supportedModes.map { it.toString() },
|
displayModes = display.supportedModes.map { it.toDisplayMode(display) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +180,13 @@ class DebugViewModel
|
||||||
itemPlaybacks.value = results
|
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 {
|
viewModelScope.launchIO {
|
||||||
val logcat = getLogCatLines()
|
val logcat = getLogCatLines()
|
||||||
this@DebugViewModel.logcat.value = logcat
|
this@DebugViewModel.logcat.value = logcat
|
||||||
|
|
@ -206,6 +224,20 @@ class DebugViewModel
|
||||||
audioManager.registerAudioDeviceCallback(callback, null)
|
audioManager.registerAudioDeviceCallback(callback, null)
|
||||||
addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
|
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 {
|
companion object {
|
||||||
|
|
@ -262,8 +294,9 @@ data class LogcatLine(
|
||||||
data class DebugState(
|
data class DebugState(
|
||||||
val appInfo: List<String> = emptyList(),
|
val appInfo: List<String> = emptyList(),
|
||||||
val deviceInfo: List<String> = emptyList(),
|
val deviceInfo: List<String> = emptyList(),
|
||||||
val displayModes: List<String> = emptyList(),
|
val displayModes: List<DisplayMode> = emptyList(),
|
||||||
val audioInfo: List<String> = emptyList(),
|
val audioInfo: List<String> = emptyList(),
|
||||||
|
val hdrSupport: Map<String, Boolean> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val AudioDeviceInfo.details: String
|
val AudioDeviceInfo.details: String
|
||||||
|
|
@ -383,6 +416,7 @@ val AudioDeviceInfo.details: String
|
||||||
"productName=$productName, address=$addressStr)"
|
"productName=$productName, address=$addressStr)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalGridApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DebugPage(
|
fun DebugPage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
|
@ -466,15 +500,54 @@ fun DebugPage(
|
||||||
BodyText(it)
|
BodyText(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
SubSectionTitle("Display Modes")
|
|
||||||
state.displayModes.forEach {
|
|
||||||
BodyText(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
SubSectionTitle("Audio Devices")
|
SubSectionTitle("Audio Devices")
|
||||||
state.audioInfo.forEach {
|
state.audioInfo.forEach {
|
||||||
BodyText(it)
|
BodyText(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SubSectionTitle("Display Modes")
|
||||||
|
Grid(
|
||||||
|
config = {
|
||||||
|
repeat(6) { column(GridTrackSize.Auto) }
|
||||||
|
flow = GridFlow.Row
|
||||||
|
columnGap(8.dp)
|
||||||
|
rowGap(8.dp)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
EmphasizedBodyText("ID")
|
||||||
|
EmphasizedBodyText("Width")
|
||||||
|
EmphasizedBodyText("Height")
|
||||||
|
EmphasizedBodyText("FPS")
|
||||||
|
EmphasizedBodyText("HDR")
|
||||||
|
EmphasizedBodyText("Alt FPS")
|
||||||
|
|
||||||
|
state.displayModes.forEachIndexed { index, mode ->
|
||||||
|
BodyText(mode.id.toString())
|
||||||
|
BodyText(mode.width.toString())
|
||||||
|
BodyText(mode.height.toString())
|
||||||
|
BodyText(mode.fps)
|
||||||
|
BodyText(mode.hdr)
|
||||||
|
BodyText(mode.altFps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubSectionTitle("Detected HDR Support")
|
||||||
|
Grid(
|
||||||
|
config = {
|
||||||
|
column(GridTrackSize.Auto)
|
||||||
|
column(GridTrackSize.Auto)
|
||||||
|
flow = GridFlow.Row
|
||||||
|
columnGap(8.dp)
|
||||||
|
rowGap(4.dp)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
EmphasizedBodyText("HDR Format")
|
||||||
|
EmphasizedBodyText("Supported")
|
||||||
|
state.hdrSupport.forEach { (name, supported) ->
|
||||||
|
BodyText(name)
|
||||||
|
BodyText(supported.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -565,11 +638,29 @@ private fun SubSectionTitle(text: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BodyText(text: String) {
|
private fun BodyText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmphasizedBodyText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -601,3 +692,45 @@ private val THIRD_PARTY_TAGS =
|
||||||
"HardwareConfig:W",
|
"HardwareConfig:W",
|
||||||
"DfltImageHeaderParser: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)"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
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.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.BlockingList
|
import com.github.damontecres.wholphin.util.BlockingList
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
|
@ -700,11 +701,25 @@ class FavoritesViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
collectionStateFor(type)?.let { collectionState ->
|
collectionStateFor(type)?.let { collectionState ->
|
||||||
val random =
|
val list =
|
||||||
(collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
|
collectionState.items.successValue as? BlockingList<BaseItem?>
|
||||||
Timber.d("Got random item: %s", random?.id)
|
if (list != null) {
|
||||||
random?.destination()?.let {
|
goToRandom(
|
||||||
navigateTo(random.destination())
|
navigateTo = ::navigateTo,
|
||||||
|
parentId = libraryDisplayItemId(type),
|
||||||
|
filter =
|
||||||
|
collectionState.filter.copy(
|
||||||
|
includeItemTypes = listOf(type),
|
||||||
|
),
|
||||||
|
sortAndDirection = collectionState.sortAndDirection,
|
||||||
|
recursive = true,
|
||||||
|
list = list,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w(
|
||||||
|
"state.value.items is not successful: %s",
|
||||||
|
collectionState.items,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: CancellationException) {
|
} catch (ex: CancellationException) {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
import com.github.damontecres.wholphin.services.HomeDataService
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MusicService
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||||
|
|
@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
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.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
|
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -75,7 +74,7 @@ class HomeRowGridViewModel
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val navDrawerService: NavDrawerService,
|
private val navDrawerService: NavDrawerService,
|
||||||
private val homeSettingsService: HomeSettingsService,
|
private val homeDataService: HomeDataService,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
|
|
@ -128,7 +127,7 @@ class HomeRowGridViewModel
|
||||||
val libraries =
|
val libraries =
|
||||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
val result =
|
val result =
|
||||||
homeSettingsService.fetchDataForRow(
|
homeDataService.fetchDataForRow(
|
||||||
row = rowConfig,
|
row = rowConfig,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ fun PersonPage(
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
itemId = person.id,
|
itemId = person.id,
|
||||||
imageType = ImageType.PRIMARY,
|
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
|
// A long filmography is hard to navigate as a row, so it is cut at the same
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -42,9 +42,8 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
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.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
import com.github.damontecres.wholphin.data.model.canRequestMovies
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
|
@ -84,14 +83,12 @@ fun DiscoverMovieDetails(
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||||
|
val requestEnabled by viewModel.requestEnabled.collectAsState(false)
|
||||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showRequestDialog by remember { mutableStateOf(false) }
|
var showRequestDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val requestStr = stringResource(R.string.request)
|
|
||||||
val request4kStr = stringResource(R.string.request_4k)
|
|
||||||
|
|
||||||
when (val st = state.movie) {
|
when (val st = state.movie) {
|
||||||
is DataLoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(st, modifier)
|
ErrorMessage(st, modifier)
|
||||||
|
|
@ -153,6 +150,7 @@ fun DiscoverMovieDetails(
|
||||||
RequestMovieDialog(
|
RequestMovieDialog(
|
||||||
loading = state.profileLoading,
|
loading = state.profileLoading,
|
||||||
data = state.requestData,
|
data = state.requestData,
|
||||||
|
requestEnabled = requestEnabled,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onSubmit = {
|
onSubmit = {
|
||||||
|
|
@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
canRequest = userConfig.canRequestMovies,
|
||||||
canCancel = canCancel,
|
canCancel = canCancel,
|
||||||
trailers = trailers,
|
trailers = trailers,
|
||||||
trailerOnClick = trailerOnClick,
|
trailerOnClick = trailerOnClick,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ class DiscoverMovieViewModel
|
||||||
val state: StateFlow<DiscoverMovieState> = _state
|
val state: StateFlow<DiscoverMovieState> = _state
|
||||||
|
|
||||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
|
val requestEnabled =
|
||||||
|
seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
|
||||||
val request4kEnabled =
|
val request4kEnabled =
|
||||||
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
||||||
|
|
||||||
|
|
@ -194,7 +196,7 @@ class DiscoverMovieViewModel
|
||||||
return@launchIO
|
return@launchIO
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
seerrService.api.requestApi.requestPost(
|
val postRequest =
|
||||||
RequestPostRequest(
|
RequestPostRequest(
|
||||||
is4k = request.is4k,
|
is4k = request.is4k,
|
||||||
mediaId = request.movieId,
|
mediaId = request.movieId,
|
||||||
|
|
@ -208,8 +210,9 @@ class DiscoverMovieViewModel
|
||||||
profileId = request.profileId,
|
profileId = request.profileId,
|
||||||
rootFolder = request.folder,
|
rootFolder = request.folder,
|
||||||
tags = emptyList(),
|
tags = emptyList(),
|
||||||
),
|
)
|
||||||
)
|
Timber.v("postRequest=%s", postRequest)
|
||||||
|
seerrService.api.requestApi.requestPost(postRequest)
|
||||||
} catch (ex: CancellationException) {
|
} catch (ex: CancellationException) {
|
||||||
throw ex
|
throw ex
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
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.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
import com.github.damontecres.wholphin.data.model.canRequestTv
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
|
@ -88,6 +87,7 @@ fun DiscoverSeriesDetails(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val requestEnabled by viewModel.requestEnabled.collectAsState()
|
||||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
|
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
@ -112,6 +112,7 @@ fun DiscoverSeriesDetails(
|
||||||
DiscoverSeriesDetailsContent(
|
DiscoverSeriesDetailsContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
series = item,
|
series = item,
|
||||||
|
availability = state.availability,
|
||||||
userConfig = userConfig,
|
userConfig = userConfig,
|
||||||
rating = state.rating,
|
rating = state.rating,
|
||||||
canCancel = state.canCancelRequest,
|
canCancel = state.canCancelRequest,
|
||||||
|
|
@ -188,6 +189,7 @@ fun DiscoverSeriesDetails(
|
||||||
title = state.tvSeries.successValue?.name ?: "",
|
title = state.tvSeries.successValue?.name ?: "",
|
||||||
seasons = state.seasons,
|
seasons = state.seasons,
|
||||||
seasons4k = state.seasons4k,
|
seasons4k = state.seasons4k,
|
||||||
|
requestEnabled = requestEnabled,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
onSubmit = {
|
onSubmit = {
|
||||||
showRequestSeasonDialog = false
|
showRequestSeasonDialog = false
|
||||||
|
|
@ -212,6 +214,7 @@ fun DiscoverSeriesDetailsContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
userConfig: SeerrUserConfig?,
|
userConfig: SeerrUserConfig?,
|
||||||
series: TvDetails,
|
series: TvDetails,
|
||||||
|
availability: SeerrAvailability,
|
||||||
rating: SourceRatings?,
|
rating: SourceRatings?,
|
||||||
canCancel: Boolean,
|
canCancel: Boolean,
|
||||||
seasons: List<RequestSeason>,
|
seasons: List<RequestSeason>,
|
||||||
|
|
@ -275,9 +278,7 @@ fun DiscoverSeriesDetailsContent(
|
||||||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandableDiscoverButtons(
|
ExpandableDiscoverButtons(
|
||||||
availability =
|
availability = availability,
|
||||||
SeerrAvailability.from(series.mediaInfo?.status)
|
|
||||||
?: SeerrAvailability.UNKNOWN,
|
|
||||||
requestOnClick = requestOnClick,
|
requestOnClick = requestOnClick,
|
||||||
pendingOnClick = requestOnClick,
|
pendingOnClick = requestOnClick,
|
||||||
cancelOnClick = cancelOnClick,
|
cancelOnClick = cancelOnClick,
|
||||||
|
|
@ -291,7 +292,7 @@ fun DiscoverSeriesDetailsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
canRequest = userConfig.canRequestTv,
|
||||||
canCancel = canCancel,
|
canCancel = canCancel,
|
||||||
trailers = trailers,
|
trailers = trailers,
|
||||||
trailerOnClick = trailerOnClick,
|
trailerOnClick = trailerOnClick,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ class DiscoverSeriesViewModel
|
||||||
val state: StateFlow<DiscoverSeriesState> = _state
|
val state: StateFlow<DiscoverSeriesState> = _state
|
||||||
|
|
||||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
|
val requestEnabled =
|
||||||
|
seerrServerRepository.current
|
||||||
|
.map { it?.requestTvEnabled ?: false }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
val request4kEnabled =
|
val request4kEnabled =
|
||||||
seerrServerRepository.current
|
seerrServerRepository.current
|
||||||
.map { it?.request4kTvEnabled ?: false }
|
.map { it?.request4kTvEnabled ?: false }
|
||||||
|
|
@ -108,7 +112,7 @@ class DiscoverSeriesViewModel
|
||||||
backdropService.submit(discoveredItem)
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
updateSeasonStatus(tv)
|
updateSeasonStatus(tv)
|
||||||
updateCanCancel()
|
updateAvailability()
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val rating =
|
val rating =
|
||||||
|
|
@ -190,6 +194,7 @@ class DiscoverSeriesViewModel
|
||||||
tv: TvDetails,
|
tv: TvDetails,
|
||||||
is4k: Boolean,
|
is4k: Boolean,
|
||||||
) {
|
) {
|
||||||
|
Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
|
||||||
val currentUserId = seerrServerRepository.currentUserId.first()
|
val currentUserId = seerrServerRepository.currentUserId.first()
|
||||||
val seasonStatus = mutableMapOf<Int, RequestStatus>()
|
val seasonStatus = mutableMapOf<Int, RequestStatus>()
|
||||||
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
|
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
|
||||||
|
|
@ -230,7 +235,10 @@ class DiscoverSeriesViewModel
|
||||||
seasonStatus[season.seasonNumber] = new
|
seasonStatus[season.seasonNumber] = new
|
||||||
}
|
}
|
||||||
editable[season.seasonNumber] =
|
editable[season.seasonNumber] =
|
||||||
currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
|
currentUserId == req.requestedBy?.id && (
|
||||||
|
req.status == RequestStatus.PENDING.status ||
|
||||||
|
req.status == RequestStatus.DECLINED.status
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -299,7 +307,7 @@ class DiscoverSeriesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateCanCancel() {
|
private suspend fun updateAvailability() {
|
||||||
val user = userConfig.firstOrNull()
|
val user = userConfig.firstOrNull()
|
||||||
val canCancel =
|
val canCancel =
|
||||||
canUserCancelRequest(
|
canUserCancelRequest(
|
||||||
|
|
@ -308,7 +316,32 @@ class DiscoverSeriesViewModel
|
||||||
?.mediaInfo
|
?.mediaInfo
|
||||||
?.requests,
|
?.requests,
|
||||||
)
|
)
|
||||||
_state.update { it.copy(canCancelRequest = canCancel) }
|
val status =
|
||||||
|
state.value.tvSeries.successValue?.let { tv ->
|
||||||
|
val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
|
||||||
|
if (overallStatus == SeerrAvailability.UNKNOWN) {
|
||||||
|
val userRequests =
|
||||||
|
tv.mediaInfo
|
||||||
|
?.requests
|
||||||
|
?.filter { it.requestedBy?.id == user?.id }
|
||||||
|
.orEmpty()
|
||||||
|
if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
|
||||||
|
SeerrAvailability.PENDING
|
||||||
|
} else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
|
||||||
|
SeerrAvailability.PROCESSING
|
||||||
|
} else {
|
||||||
|
SeerrAvailability.UNKNOWN
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
overallStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
canCancelRequest = canCancel,
|
||||||
|
availability = status ?: SeerrAvailability.UNKNOWN,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestOnClick() {
|
fun requestOnClick() {
|
||||||
|
|
@ -391,7 +424,7 @@ class DiscoverSeriesViewModel
|
||||||
|
|
||||||
fetchAndSetItem().await()?.let {
|
fetchAndSetItem().await()?.let {
|
||||||
updateSeasonStatus(it)
|
updateSeasonStatus(it)
|
||||||
updateCanCancel()
|
updateAvailability()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +445,7 @@ class DiscoverSeriesViewModel
|
||||||
|
|
||||||
fetchAndSetItem().await()?.let {
|
fetchAndSetItem().await()?.let {
|
||||||
updateSeasonStatus(it)
|
updateSeasonStatus(it)
|
||||||
updateCanCancel()
|
updateAvailability()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -431,4 +464,5 @@ data class DiscoverSeriesState(
|
||||||
val canCancelRequest: Boolean = false,
|
val canCancelRequest: Boolean = false,
|
||||||
val profileLoading: LoadingState = LoadingState.Pending,
|
val profileLoading: LoadingState = LoadingState.Pending,
|
||||||
val requestData: SeerrRequestData = SeerrRequestData(),
|
val requestData: SeerrRequestData = SeerrRequestData(),
|
||||||
|
val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
||||||
fun RequestMovieDialog(
|
fun RequestMovieDialog(
|
||||||
loading: LoadingState,
|
loading: LoadingState,
|
||||||
data: SeerrRequestData,
|
data: SeerrRequestData,
|
||||||
|
requestEnabled: Boolean,
|
||||||
request4kEnabled: Boolean,
|
request4kEnabled: Boolean,
|
||||||
movie: MovieDetails,
|
movie: MovieDetails,
|
||||||
onSubmit: (MovieRequest) -> Unit,
|
onSubmit: (MovieRequest) -> Unit,
|
||||||
|
|
@ -54,10 +55,11 @@ fun RequestMovieDialog(
|
||||||
RequestMovie(
|
RequestMovie(
|
||||||
loading = loading,
|
loading = loading,
|
||||||
data = data,
|
data = data,
|
||||||
|
requestEnabled = requestEnabled,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +68,7 @@ fun RequestMovieDialog(
|
||||||
fun RequestMovie(
|
fun RequestMovie(
|
||||||
loading: LoadingState,
|
loading: LoadingState,
|
||||||
data: SeerrRequestData,
|
data: SeerrRequestData,
|
||||||
|
requestEnabled: Boolean,
|
||||||
request4kEnabled: Boolean,
|
request4kEnabled: Boolean,
|
||||||
movie: MovieDetails,
|
movie: MovieDetails,
|
||||||
onSubmit: (MovieRequest) -> Unit,
|
onSubmit: (MovieRequest) -> Unit,
|
||||||
|
|
@ -90,7 +93,8 @@ fun RequestMovie(
|
||||||
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
|
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
|
||||||
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
|
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
|
||||||
) {
|
) {
|
||||||
if (request4kEnabled) {
|
// Both enabled, show choice
|
||||||
|
if (requestEnabled && request4kEnabled) {
|
||||||
val items =
|
val items =
|
||||||
remember {
|
remember {
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -118,12 +122,13 @@ fun RequestMovie(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
|
onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
RequestMovieWithOptions(
|
RequestMovieWithOptions(
|
||||||
data = data,
|
data = data,
|
||||||
|
requestEnabled = requestEnabled,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
|
|
@ -137,12 +142,13 @@ fun RequestMovie(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RequestMovieWithOptions(
|
private fun RequestMovieWithOptions(
|
||||||
data: SeerrRequestData,
|
data: SeerrRequestData,
|
||||||
|
requestEnabled: Boolean,
|
||||||
request4kEnabled: Boolean,
|
request4kEnabled: Boolean,
|
||||||
movie: MovieDetails,
|
movie: MovieDetails,
|
||||||
onSubmit: (MovieRequest) -> Unit,
|
onSubmit: (MovieRequest) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var is4k by remember { mutableStateOf(request4kEnabled) }
|
var is4k by remember { mutableStateOf(!requestEnabled) }
|
||||||
var profile by remember(is4k) {
|
var profile by remember(is4k) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (is4k) {
|
if (is4k) {
|
||||||
|
|
@ -185,7 +191,7 @@ private fun RequestMovieWithOptions(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 8.dp),
|
.padding(bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
if (request4kEnabled) {
|
if (requestEnabled && request4kEnabled) {
|
||||||
ClickSwitch(
|
ClickSwitch(
|
||||||
label = stringResource(R.string.request_4k),
|
label = stringResource(R.string.request_4k),
|
||||||
checked = is4k,
|
checked = is4k,
|
||||||
|
|
@ -377,7 +383,7 @@ fun MovieRequestPreview() {
|
||||||
),
|
),
|
||||||
profiles4k =
|
profiles4k =
|
||||||
listOf(
|
listOf(
|
||||||
SeerrProfile(1, "HD", true),
|
SeerrProfile(1, "4K", true),
|
||||||
SeerrProfile(2, "Ultra HD", false),
|
SeerrProfile(2, "Ultra HD", false),
|
||||||
),
|
),
|
||||||
rootFolders4k =
|
rootFolders4k =
|
||||||
|
|
@ -385,7 +391,8 @@ fun MovieRequestPreview() {
|
||||||
SeerrRootFolder(1, "/movies", "400GB", true),
|
SeerrRootFolder(1, "/movies", "400GB", true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
request4kEnabled = false,
|
requestEnabled = true,
|
||||||
|
request4kEnabled = true,
|
||||||
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
|
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
|
||||||
onSubmit = {},
|
onSubmit = {},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,12 @@ fun RequestSeasons(
|
||||||
seasons: List<RequestSeason>,
|
seasons: List<RequestSeason>,
|
||||||
seasons4k: List<RequestSeason>,
|
seasons4k: List<RequestSeason>,
|
||||||
data: SeerrRequestData,
|
data: SeerrRequestData,
|
||||||
|
requestEnabled: Boolean,
|
||||||
request4kEnabled: Boolean,
|
request4kEnabled: Boolean,
|
||||||
onSubmit: (TvRequest) -> Unit,
|
onSubmit: (TvRequest) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var is4k by remember { mutableStateOf(request4kEnabled) }
|
var is4k by remember { mutableStateOf(!requestEnabled) }
|
||||||
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
|
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
|
||||||
|
|
||||||
val allSeasonNumbers =
|
val allSeasonNumbers =
|
||||||
|
|
@ -161,7 +162,7 @@ fun RequestSeasons(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
if (request4kEnabled) {
|
if (requestEnabled && request4kEnabled) {
|
||||||
ClickSwitch(
|
ClickSwitch(
|
||||||
label = stringResource(R.string.request_4k),
|
label = stringResource(R.string.request_4k),
|
||||||
checked = is4k,
|
checked = is4k,
|
||||||
|
|
@ -391,6 +392,7 @@ fun RequestSeasonsDialog(
|
||||||
data: SeerrRequestData,
|
data: SeerrRequestData,
|
||||||
seasons: List<RequestSeason>,
|
seasons: List<RequestSeason>,
|
||||||
seasons4k: List<RequestSeason>,
|
seasons4k: List<RequestSeason>,
|
||||||
|
requestEnabled: Boolean,
|
||||||
request4kEnabled: Boolean,
|
request4kEnabled: Boolean,
|
||||||
onSubmit: (TvRequest) -> Unit,
|
onSubmit: (TvRequest) -> Unit,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
|
|
@ -418,6 +420,7 @@ fun RequestSeasonsDialog(
|
||||||
data = data,
|
data = data,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
seasons4k = seasons4k,
|
seasons4k = seasons4k,
|
||||||
|
requestEnabled = requestEnabled,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -475,6 +478,7 @@ fun RequestSeasonsPreview() {
|
||||||
SeerrRootFolder(1, "/tv", "400GB", true),
|
SeerrRootFolder(1, "/tv", "400GB", true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
requestEnabled = true,
|
||||||
request4kEnabled = true,
|
request4kEnabled = true,
|
||||||
onSubmit = { },
|
onSubmit = { },
|
||||||
modifier = Modifier.width(400.dp),
|
modifier = Modifier.width(400.dp),
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,10 @@ class EpisodeViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +162,6 @@ class EpisodeViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -171,10 +169,7 @@ class EpisodeViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,11 @@ class LiveTvViewModel
|
||||||
number = it.channelNumber,
|
number = it.channelNumber,
|
||||||
name = it.channelName ?: it.name,
|
name = it.channelName ?: it.name,
|
||||||
imageUrl =
|
imageUrl =
|
||||||
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = it.id,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
tag = it.imageTags?.get(ImageType.PRIMARY),
|
||||||
|
),
|
||||||
favorite = it.userData?.isFavorite == true,
|
favorite = it.userData?.isFavorite == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +303,7 @@ class LiveTvViewModel
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
dto.id,
|
dto.id,
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
|
tag = dto.imageTags?.get(ImageType.PRIMARY),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,10 @@ class MovieViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +240,6 @@ class MovieViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -249,10 +247,7 @@ class MovieViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -561,11 +561,10 @@ class SeriesViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||||
val chosen =
|
val chosen =
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
}
|
}
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
|
|
@ -579,7 +578,6 @@ class SeriesViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -587,10 +585,7 @@ class SeriesViewModel
|
||||||
trackIndex = trackIndex,
|
trackIndex = trackIndex,
|
||||||
type = type,
|
type = type,
|
||||||
)
|
)
|
||||||
val chosen =
|
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||||
result?.let {
|
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(chosenStreams = chosen) }
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
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.HomePageResolvedSettings
|
||||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
|
|
@ -62,6 +63,7 @@ class HomeViewModel
|
||||||
val serverReportService: ServerReportService,
|
val serverReportService: ServerReportService,
|
||||||
private val navDrawerService: NavDrawerService,
|
private val navDrawerService: NavDrawerService,
|
||||||
private val homeSettingsService: HomeSettingsService,
|
private val homeSettingsService: HomeSettingsService,
|
||||||
|
private val homeDataService: HomeDataService,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
|
@ -186,7 +188,7 @@ class HomeViewModel
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
Timber.v("Fetching row: %s", row)
|
Timber.v("Fetching row: %s", row)
|
||||||
try {
|
try {
|
||||||
homeSettingsService.fetchDataForRow(
|
homeDataService.fetchDataForRow(
|
||||||
row = row.config,
|
row = row.config,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object GlobalSettings : HomeSettingsDestination
|
data object GlobalSettings : HomeSettingsDestination
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object SourceSettings : HomeSettingsDestination
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Presets : HomeSettingsDestination
|
data object Presets : HomeSettingsDestination
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ fun HomeSettingsGlobal(
|
||||||
preferences: AppPreferences,
|
preferences: AppPreferences,
|
||||||
onPreferenceChange: (AppPreferences) -> Unit,
|
onPreferenceChange: (AppPreferences) -> Unit,
|
||||||
onClickResize: (Int) -> Unit,
|
onClickResize: (Int) -> Unit,
|
||||||
onClickSave: () -> Unit,
|
|
||||||
onClickLoad: () -> Unit,
|
|
||||||
onClickLoadWeb: () -> Unit,
|
|
||||||
onClickReset: () -> Unit,
|
|
||||||
onClickViewNextUp: () -> Unit,
|
onClickViewNextUp: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -146,62 +142,6 @@ fun HomeSettingsGlobal(
|
||||||
modifier = Modifier,
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.main.settings
|
package com.github.damontecres.wholphin.ui.main.settings
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -64,11 +65,14 @@ fun HomeSettingsPage(
|
||||||
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
||||||
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
|
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
|
||||||
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
|
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
|
||||||
|
var showSaveDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val serverPluginActive by viewModel.serverPluginActive.collectAsState()
|
||||||
var position by rememberPosition(0, 0)
|
var position by rememberPosition(0, 0)
|
||||||
// TODO discover rows
|
// TODO discover rows
|
||||||
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
|
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
|
// Adds a row, waits until its done loading, then scrolls to the new row
|
||||||
fun addRow(
|
fun addRow(
|
||||||
|
|
@ -86,6 +90,10 @@ fun HomeSettingsPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(settingsHaveChanged) {
|
||||||
|
showSaveDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -137,6 +145,7 @@ fun HomeSettingsPage(
|
||||||
position = RowColumn(index, 0)
|
position = RowColumn(index, 0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
|
||||||
modifier = destModifier,
|
modifier = destModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +291,16 @@ fun HomeSettingsPage(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClickResize = { viewModel.resizeCards(it) },
|
onClickResize = { viewModel.resizeCards(it) },
|
||||||
|
onClickViewNextUp = {
|
||||||
|
showRemovedNextUpDialog = true
|
||||||
|
},
|
||||||
|
modifier = destModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeSettingsDestination.SourceSettings -> {
|
||||||
|
HomeSettingsSourcePage(
|
||||||
|
source = state.source,
|
||||||
onClickSave = {
|
onClickSave = {
|
||||||
showConfirmDialog =
|
showConfirmDialog =
|
||||||
ShowConfirm(R.string.overwrite_server_settings) {
|
ShowConfirm(R.string.overwrite_server_settings) {
|
||||||
|
|
@ -294,6 +313,13 @@ fun HomeSettingsPage(
|
||||||
viewModel.loadFromRemote()
|
viewModel.loadFromRemote()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
serverPluginActive = serverPluginActive,
|
||||||
|
onClickLoadPlugin = {
|
||||||
|
showConfirmDialog =
|
||||||
|
ShowConfirm(R.string.overwrite_local_settings) {
|
||||||
|
viewModel.loadFromRemotePlugin()
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickLoadWeb = {
|
onClickLoadWeb = {
|
||||||
showConfirmDialog =
|
showConfirmDialog =
|
||||||
ShowConfirm(R.string.overwrite_local_settings) {
|
ShowConfirm(R.string.overwrite_local_settings) {
|
||||||
|
|
@ -306,9 +332,6 @@ fun HomeSettingsPage(
|
||||||
addRow(false) { viewModel.resetToDefault() }
|
addRow(false) { viewModel.resetToDefault() }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClickViewNextUp = {
|
|
||||||
showRemovedNextUpDialog = true
|
|
||||||
},
|
|
||||||
modifier = destModifier,
|
modifier = destModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -375,6 +398,29 @@ fun HomeSettingsPage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (showSaveDialog) {
|
||||||
|
SaveHomeSettingsDialog(
|
||||||
|
onDismissRequest = { showSaveDialog = false },
|
||||||
|
onSaveLocal = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveToLocal().join()
|
||||||
|
viewModel.navigationManager.goBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSaveServer = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.saveToRemote().join()
|
||||||
|
viewModel.navigationManager.goBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDiscard = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.discardChanges().join()
|
||||||
|
viewModel.navigationManager.goBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ShowConfirm(
|
data class ShowConfirm(
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ enum class MoveDirection {
|
||||||
DOWN,
|
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
|
@Composable
|
||||||
fun HomeSettingsRowList(
|
fun HomeSettingsRowList(
|
||||||
state: HomePageSettingsState,
|
state: HomePageSettingsState,
|
||||||
|
|
@ -58,13 +63,14 @@ fun HomeSettingsRowList(
|
||||||
onClickPresets: () -> Unit,
|
onClickPresets: () -> Unit,
|
||||||
onClickMove: (MoveDirection, Int) -> Unit,
|
onClickMove: (MoveDirection, Int) -> Unit,
|
||||||
onClickDelete: (Int) -> Unit,
|
onClickDelete: (Int) -> Unit,
|
||||||
|
onClickSourceSettings: () -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val itemsBeforeRows = 4
|
val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
|
||||||
val focusRequesters =
|
val focusRequesters =
|
||||||
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
|
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
|
||||||
|
|
||||||
|
|
@ -80,7 +86,7 @@ fun HomeSettingsRowList(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.focusRestorer(focusRequesters[0]),
|
.focusRestorer(focusRequesters[POS_ADD_ROW]),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
HomeSettingsListItem(
|
HomeSettingsListItem(
|
||||||
|
|
@ -93,10 +99,30 @@ fun HomeSettingsRowList(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
position = 0
|
position = POS_ADD_ROW
|
||||||
onClickAdd.invoke()
|
onClickAdd.invoke()
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(focusRequesters[0]),
|
modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
HomeSettingsListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineText = "Source",
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_download),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(state.source.stringResId))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
position = POS_SOURCE
|
||||||
|
onClickSourceSettings.invoke()
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -110,10 +136,10 @@ fun HomeSettingsRowList(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
position = 1
|
position = POS_SETTINGS
|
||||||
onClickSettings.invoke()
|
onClickSettings.invoke()
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -132,10 +158,10 @@ fun HomeSettingsRowList(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
position = 2
|
position = POS_PRESETS
|
||||||
onClickPresets.invoke()
|
onClickPresets.invoke()
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(focusRequesters[2]),
|
modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.main.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||||
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeSettingsSourcePage(
|
||||||
|
source: HomePageSettingsSource,
|
||||||
|
onClickSave: () -> Unit,
|
||||||
|
onClickLoad: () -> Unit,
|
||||||
|
onClickLoadWeb: () -> Unit,
|
||||||
|
onClickReset: () -> Unit,
|
||||||
|
serverPluginActive: Boolean,
|
||||||
|
onClickLoadPlugin: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val firstFocus: FocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
HomeSettingsListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineText = stringResource(R.string.load_from_server),
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_cloud_arrow_down),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClickLoad,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (serverPluginActive) {
|
||||||
|
item {
|
||||||
|
HomeSettingsListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineText = stringResource(R.string.load_from_server_plugin),
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_download),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClickLoadPlugin,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
HomeSettingsListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineText = stringResource(R.string.load_from_web_client),
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_download),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClickLoadWeb,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
HomeSettingsListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineText = stringResource(R.string.reset),
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_arrows_rotate),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClickReset,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
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.HomePageResolvedSettings
|
||||||
|
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||||
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
||||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
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.SeerrServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.ServerPluginApi
|
||||||
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
|
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
|
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.services.viewOptionsForCollectionType
|
||||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
|
@ -76,37 +81,55 @@ class HomeSettingsViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val homeSettingsService: HomeSettingsService,
|
private val homeSettingsService: HomeSettingsService,
|
||||||
|
private val homeDataService: HomeDataService,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val navDrawerService: NavDrawerService,
|
private val navDrawerService: NavDrawerService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val serverPluginApi: ServerPluginApi,
|
||||||
val preferencesDataStore: DataStore<AppPreferences>,
|
val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
||||||
val state: StateFlow<HomePageSettingsState> = _state
|
val state: StateFlow<HomePageSettingsState> = _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<Int>()
|
private var idCounter by Delegates.notNull<Int>()
|
||||||
|
|
||||||
val discoverEnabled = seerrServerRepository.active
|
val discoverEnabled = seerrServerRepository.active
|
||||||
|
|
||||||
private var originalLocalSettings: HomePageSettings? = null
|
private var originalSettings: HomePageSettings? = null
|
||||||
private var originalRemoteSettings: HomePageSettings? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable { saveToLocal() }
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
val user = serverRepository.currentUser ?: return@launchIO
|
||||||
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
||||||
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
val currentSettings =
|
val currentSettings =
|
||||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||||
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
|
originalSettings = currentSettings.asHomePageSettings()
|
||||||
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
|
|
||||||
Timber.v("currentSettings=%s", currentSettings)
|
Timber.v("currentSettings=%s", currentSettings)
|
||||||
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
source = currentSettings.source,
|
||||||
libraries = libraries,
|
libraries = libraries,
|
||||||
rows = currentSettings.rows,
|
rows = currentSettings.rows,
|
||||||
)
|
)
|
||||||
|
|
@ -134,7 +157,7 @@ class HomeSettingsViewModel
|
||||||
viewModelScope.async(WholphinDispatchers.IO) {
|
viewModelScope.async(WholphinDispatchers.IO) {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
try {
|
try {
|
||||||
homeSettingsService.fetchDataForRow(
|
homeDataService.fetchDataForRow(
|
||||||
row = row.config,
|
row = row.config,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
|
|
@ -496,7 +519,7 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToRemote() {
|
fun saveToRemote(): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
Timber.d("Saving home settings to remote")
|
Timber.d("Saving home settings to remote")
|
||||||
|
|
@ -506,6 +529,10 @@ class HomeSettingsViewModel
|
||||||
try {
|
try {
|
||||||
Timber.d("saveToRemote")
|
Timber.d("saveToRemote")
|
||||||
homeSettingsService.saveToServer(user.id, settings)
|
homeSettingsService.saveToServer(user.id, settings)
|
||||||
|
homeSettingsService.updateCurrent(
|
||||||
|
HomePageSettingsSource.SERVER_PROFILE,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
showSaveToast()
|
showSaveToast()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex)
|
||||||
|
|
@ -513,7 +540,6 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun loadFromRemote() {
|
fun loadFromRemote() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
|
@ -530,7 +556,10 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(rows = newRows)
|
it.copy(
|
||||||
|
source = HomePageSettingsSource.SERVER_PROFILE,
|
||||||
|
rows = newRows,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.v("No remote settings")
|
Timber.v("No remote settings")
|
||||||
|
|
@ -560,7 +589,10 @@ class HomeSettingsViewModel
|
||||||
Timber.v("Got web settings")
|
Timber.v("Got web settings")
|
||||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(rows = result.rows)
|
it.copy(
|
||||||
|
source = HomePageSettingsSource.LOCAL,
|
||||||
|
rows = result.rows,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.v("No web settings")
|
Timber.v("No web settings")
|
||||||
|
|
@ -575,34 +607,19 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToLocal() {
|
fun saveToLocal(): Job =
|
||||||
// This uses injected ioScope so that it will still run when the page is closing
|
viewModelScope.launchIO {
|
||||||
ioScope.launchIO {
|
|
||||||
serverRepository.currentUser?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val rows = state.value.rows.map { it.config }
|
val rows = state.value.rows.map { it.config }
|
||||||
val settings =
|
val settings =
|
||||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||||
try {
|
try {
|
||||||
Timber.d("saveToLocal")
|
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.saveToLocal(user.id, settings)
|
||||||
|
homeSettingsService.updateCurrent(
|
||||||
|
HomePageSettingsSource.LOCAL,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
showSaveToast()
|
showSaveToast()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex)
|
||||||
|
|
@ -610,17 +627,11 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
|
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
|
||||||
_state.update {
|
_state.update {
|
||||||
update.invoke(it)
|
update.invoke(it)
|
||||||
}
|
}
|
||||||
serverRepository.currentUser?.id?.let { userId ->
|
|
||||||
homeSettingsService.currentSettings.update {
|
|
||||||
HomePageResolvedSettings(userId, state.value.rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resizeCards(relative: Int) {
|
fun resizeCards(relative: Int) {
|
||||||
|
|
@ -649,12 +660,32 @@ class HomeSettingsViewModel
|
||||||
|
|
||||||
fun resetToDefault() =
|
fun resetToDefault() =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val userId = serverRepository.currentUser?.id ?: return@launchIO
|
val user = serverRepository.currentUser ?: return@launchIO
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
val result = homeSettingsService.createDefault(userId)
|
val result =
|
||||||
|
if (serverPluginActive.value) {
|
||||||
|
try {
|
||||||
|
val settings =
|
||||||
|
homeSettingsService.fetchSettingsFor(
|
||||||
|
user.id,
|
||||||
|
HomePageSettingsSource.PLUGIN,
|
||||||
|
)
|
||||||
|
settings
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Plugin is active, but error fetching home settings")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
} ?: homeSettingsService.createDefault(user.id)
|
||||||
|
|
||||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(rows = result.rows, rowData = emptyList())
|
it.copy(
|
||||||
|
source = result.source,
|
||||||
|
rows = result.rows,
|
||||||
|
rowData = emptyList(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fetchRowData()
|
fetchRowData()
|
||||||
}
|
}
|
||||||
|
|
@ -746,6 +777,10 @@ class HomeSettingsViewModel
|
||||||
is HomeRowConfig.TvChannels -> {
|
is HomeRowConfig.TvChannels -> {
|
||||||
it.config.updateViewOptions(preset.liveTv)
|
it.config.updateViewOptions(preset.liveTv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is HomeRowConfig.CustomEndpoint -> {
|
||||||
|
it.config
|
||||||
|
}
|
||||||
}
|
}
|
||||||
it.copy(config = newConfig)
|
it.copy(config = newConfig)
|
||||||
}
|
}
|
||||||
|
|
@ -767,6 +802,43 @@ class HomeSettingsViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadFromRemotePlugin() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
Timber.d("Loading home settings from server plugin")
|
||||||
|
try {
|
||||||
|
// TODO should this remove local settings?
|
||||||
|
// TODO how to set up priorities
|
||||||
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
|
val result = serverPluginApi.fetchHomePageSettings()
|
||||||
|
if (result != null) {
|
||||||
|
Timber.v("Got remote settings")
|
||||||
|
val newRows =
|
||||||
|
result.rows.mapIndexed { index, config ->
|
||||||
|
homeSettingsService.resolve(index, config)
|
||||||
|
}
|
||||||
|
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
source = HomePageSettingsSource.PLUGIN,
|
||||||
|
rows = newRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.v("No server plugin settings")
|
||||||
|
showToast(context, "No server plugin settings found")
|
||||||
|
}
|
||||||
|
fetchRowData()
|
||||||
|
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||||
|
// TODO
|
||||||
|
Timber.w(ex)
|
||||||
|
showToast(context, "Error: ${ex.localizedMessage}")
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex)
|
||||||
|
showToast(context, "Error: ${ex.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onConfigAction(
|
fun onConfigAction(
|
||||||
row: HomeRowConfigDisplay,
|
row: HomeRowConfigDisplay,
|
||||||
action: HomeRowConfigAction,
|
action: HomeRowConfigAction,
|
||||||
|
|
@ -861,10 +933,13 @@ class HomeSettingsViewModel
|
||||||
fetchRowData()
|
fetchRowData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun discardChanges(): Job = Job().apply { complete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class HomePageSettingsState(
|
data class HomePageSettingsState(
|
||||||
val loading: LoadingState,
|
val loading: LoadingState,
|
||||||
|
val source: HomePageSettingsSource,
|
||||||
val rows: List<HomeRowConfigDisplay>,
|
val rows: List<HomeRowConfigDisplay>,
|
||||||
val rowData: List<HomeRowLoadingState>,
|
val rowData: List<HomeRowLoadingState>,
|
||||||
val libraries: List<Library>,
|
val libraries: List<Library>,
|
||||||
|
|
@ -873,6 +948,7 @@ data class HomePageSettingsState(
|
||||||
val EMPTY =
|
val EMPTY =
|
||||||
HomePageSettingsState(
|
HomePageSettingsState(
|
||||||
LoadingState.Pending,
|
LoadingState.Pending,
|
||||||
|
HomePageSettingsSource.UNSET,
|
||||||
emptyList(),
|
emptyList(),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.main.settings
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveHomeSettingsDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSaveLocal: () -> Unit,
|
||||||
|
onSaveServer: () -> Unit,
|
||||||
|
onDiscard: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val params =
|
||||||
|
remember(resources) {
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = resources.getString(R.string.save),
|
||||||
|
items =
|
||||||
|
listOf(
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.save_to_local,
|
||||||
|
iconStringRes = R.string.fa_cloud_arrow_down,
|
||||||
|
onClick = onSaveLocal,
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
text = R.string.save_to_server,
|
||||||
|
iconStringRes = R.string.fa_cloud_arrow_up,
|
||||||
|
onClick = onSaveServer,
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
text = "Discard",
|
||||||
|
icon = Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
onClick = onDiscard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
params = params,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
elevation = 3.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -134,7 +134,7 @@ sealed class Destination(
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Slideshow(
|
data class Slideshow(
|
||||||
val parentId: UUID,
|
val parentId: UUID?,
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val filter: CollectionFolderFilter,
|
val filter: CollectionFolderFilter,
|
||||||
val sortAndDirection: SortAndDirection,
|
val sortAndDirection: SortAndDirection,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
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.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
|
|
@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
selected = false,
|
selected = false,
|
||||||
|
|
@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier,
|
modifier = Modifier.enableMarquee(focused),
|
||||||
text = user.name ?: user.id.toString(),
|
text = user.name ?: user.id.toString(),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier,
|
modifier = Modifier.enableMarquee(focused),
|
||||||
text = text,
|
text = text,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier,
|
modifier = Modifier.enableMarquee(focused),
|
||||||
text = library.name(context),
|
text = library.name(context),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||||
|
|
@ -158,7 +159,11 @@ class PlayExternalViewModel
|
||||||
is PlaylistItem.Media -> playlistItem.item
|
is PlaylistItem.Media -> playlistItem.item
|
||||||
}
|
}
|
||||||
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
val subtitleStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
item.data,
|
||||||
|
SeriesTrackChoiceType.SUBTITLE,
|
||||||
|
)
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
Timber.w("Media source is null")
|
Timber.w("Media source is null")
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
|
|
@ -168,13 +173,14 @@ class PlayExternalViewModel
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseSubtitleStream(
|
.chooseSubtitleStream(
|
||||||
|
item = item.data,
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = null,
|
audioStream = null,
|
||||||
seriesId = item.data.seriesId,
|
|
||||||
itemPlayback = playbackConfig,
|
itemPlayback = playbackConfig,
|
||||||
plc = plc,
|
stc = subtitleStc,
|
||||||
prefs = prefs,
|
prefs = prefs,
|
||||||
)?.index
|
)?.stream
|
||||||
|
?.index
|
||||||
val externalSubtitles =
|
val externalSubtitles =
|
||||||
mediaSource.mediaStreams
|
mediaSource.mediaStreams
|
||||||
?.filter { it.isExternal }
|
?.filter { it.isExternal }
|
||||||
|
|
@ -237,6 +243,8 @@ class PlayExternalViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// VLC
|
// VLC
|
||||||
|
// A zero position alone does not override VLC's saved resume position.
|
||||||
|
putExtra("from_start", positionMs == 0L)
|
||||||
if (subtitleUrls.isNotEmpty()) {
|
if (subtitleUrls.isNotEmpty()) {
|
||||||
putExtra("subtitles_location", subtitleUrls.first().toString())
|
putExtra("subtitles_location", subtitleUrls.first().toString())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,10 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
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.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
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.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||||
|
|
@ -60,6 +62,7 @@ enum class PlaybackDialogType {
|
||||||
PLAYBACK_SPEED,
|
PLAYBACK_SPEED,
|
||||||
VIDEO_SCALE,
|
VIDEO_SCALE,
|
||||||
SUBTITLE_DELAY,
|
SUBTITLE_DELAY,
|
||||||
|
BITRATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlaybackSettings(
|
data class PlaybackSettings(
|
||||||
|
|
@ -73,6 +76,7 @@ data class PlaybackSettings(
|
||||||
val subtitleDelay: Duration,
|
val subtitleDelay: Duration,
|
||||||
val hasSubtitleDownloadPermission: Boolean,
|
val hasSubtitleDownloadPermission: Boolean,
|
||||||
val playbackSpeedEnabled: Boolean,
|
val playbackSpeedEnabled: Boolean,
|
||||||
|
val maxBitrate: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -162,6 +166,13 @@ fun PlaybackDialog(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
add(
|
||||||
|
BottomDialogItem(
|
||||||
|
data = PlaybackDialogType.BITRATE,
|
||||||
|
headline = stringResource(R.string.max_bitrate),
|
||||||
|
supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) },
|
||||||
|
),
|
||||||
|
)
|
||||||
add(
|
add(
|
||||||
BottomDialogItem(
|
BottomDialogItem(
|
||||||
data = PlaybackDialogType.DEBUG,
|
data = PlaybackDialogType.DEBUG,
|
||||||
|
|
@ -271,6 +282,29 @@ fun PlaybackDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackDialogType.BITRATE -> {
|
||||||
|
val choices =
|
||||||
|
AppPreference.bitrateValues.map { bitrate ->
|
||||||
|
BottomDialogItem(
|
||||||
|
data = bitrate,
|
||||||
|
headline = formatMaxBitrate(bitrate),
|
||||||
|
supporting = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BottomDialog(
|
||||||
|
choices = choices,
|
||||||
|
currentChoice = choices.firstOrNull { it.data == settings.maxBitrate },
|
||||||
|
onDismissRequest = {
|
||||||
|
onControllerInteraction.invoke()
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
},
|
||||||
|
onSelectChoice = { _, choice ->
|
||||||
|
onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data))
|
||||||
|
},
|
||||||
|
gravity = leftGravity,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,10 @@ fun PlaybackPageContent(
|
||||||
player.seekToPrevious()
|
player.seekToPrevious()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ChangeBitrate -> {
|
||||||
|
viewModel.changeMaxBitrate(it.maxBitrate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,6 +756,7 @@ fun PlaybackPageContent(
|
||||||
// TODO Passing through audio prevents changing playback speed
|
// TODO Passing through audio prevents changing playback speed
|
||||||
// See https://github.com/damontecres/Wholphin/issues/164
|
// See https://github.com/damontecres/Wholphin/issues/164
|
||||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
|
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
|
||||||
|
maxBitrate = state.maxBitrate,
|
||||||
),
|
),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
playbackDialog =
|
playbackDialog =
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ data class PlaybackState(
|
||||||
val nextUp: BaseItem? = null,
|
val nextUp: BaseItem? = null,
|
||||||
val playlistIndex: Int = 0,
|
val playlistIndex: Int = 0,
|
||||||
val playlist: Playlist = Playlist(emptyList()),
|
val playlist: Playlist = Playlist(emptyList()),
|
||||||
|
val maxBitrate: Long = -1L,
|
||||||
) {
|
) {
|
||||||
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
|
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
|
||||||
val hasPrevious: Boolean get() = playlistIndex > 0
|
val hasPrevious: Boolean get() = playlistIndex > 0
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import android.media.MediaCodecList
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Chapter
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
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.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
|
|
@ -198,7 +200,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
val currentUserDto = serverRepository.currentUserDtoFlow
|
val currentUserDto = serverRepository.currentUserDtoFlow
|
||||||
|
|
||||||
// Exposed for testing
|
@VisibleForTesting
|
||||||
val initJob: Job
|
val initJob: Job
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -290,8 +292,16 @@ class PlaybackViewModel
|
||||||
*/
|
*/
|
||||||
private suspend fun init() {
|
private suspend fun init() {
|
||||||
musicService.stop()
|
musicService.stop()
|
||||||
_state.update { it.copy(nextUp = null) }
|
|
||||||
this.preferences = userPreferencesService.getCurrent()
|
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) {
|
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||||
addCloseable { refreshRateService.resetRefreshRate() }
|
addCloseable { refreshRateService.resetRefreshRate() }
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +499,16 @@ class PlaybackViewModel
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
val audioStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
base,
|
||||||
|
SeriesTrackChoiceType.AUDIO,
|
||||||
|
)
|
||||||
|
val subtitleStc =
|
||||||
|
streamChoiceService.getSeriesTrackChoices(
|
||||||
|
base,
|
||||||
|
SeriesTrackChoiceType.AUDIO,
|
||||||
|
)
|
||||||
|
|
||||||
if (mediaSource == null && !isLiveTv) {
|
if (mediaSource == null && !isLiveTv) {
|
||||||
showToast(
|
showToast(
|
||||||
|
|
@ -518,30 +537,31 @@ class PlaybackViewModel
|
||||||
|
|
||||||
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
|
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
|
||||||
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
|
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
|
||||||
val audioStream =
|
val audioResult =
|
||||||
mediaSource?.let {
|
mediaSource?.let {
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseAudioStream(
|
.chooseAudioStream(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
seriesId = base.seriesId,
|
item = base,
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
stc = audioStc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val audioIndex = audioStream?.index
|
val audioIndex = audioResult?.stream?.index
|
||||||
|
|
||||||
val subtitleIndex =
|
val subtitleIndex =
|
||||||
mediaSource?.let {
|
mediaSource?.let {
|
||||||
streamChoiceService
|
streamChoiceService
|
||||||
.chooseSubtitleStream(
|
.chooseSubtitleStream(
|
||||||
|
item = base,
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
audioStream = audioStream,
|
audioStream = audioResult?.stream,
|
||||||
seriesId = base.seriesId,
|
|
||||||
itemPlayback = itemPlayback,
|
itemPlayback = itemPlayback,
|
||||||
plc = plc,
|
stc = subtitleStc,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)?.index
|
)?.stream
|
||||||
|
?.index
|
||||||
}
|
}
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
|
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
|
||||||
|
|
@ -616,6 +636,7 @@ class PlaybackViewModel
|
||||||
positionMs: Long = 0,
|
positionMs: Long = 0,
|
||||||
enableDirectPlay: Boolean = !this.forceTranscoding,
|
enableDirectPlay: Boolean = !this.forceTranscoding,
|
||||||
enableDirectStream: Boolean = !this.forceTranscoding,
|
enableDirectStream: Boolean = !this.forceTranscoding,
|
||||||
|
maxBitrate: Long = this.state.value.maxBitrate,
|
||||||
): Unit =
|
): Unit =
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
val itemId = item.id
|
val itemId = item.id
|
||||||
|
|
@ -626,7 +647,8 @@ class PlaybackViewModel
|
||||||
state.value.currentPlayback?.let { currentPlayback ->
|
state.value.currentPlayback?.let { currentPlayback ->
|
||||||
if (currentPlayback.item.id == item.id &&
|
if (currentPlayback.item.id == item.id &&
|
||||||
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
|
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
|
||||||
enableDirectPlay
|
enableDirectPlay &&
|
||||||
|
state.value.maxBitrate == maxBitrate
|
||||||
) {
|
) {
|
||||||
val wasSuccessful =
|
val wasSuccessful =
|
||||||
changeStreamsDirectPlay(
|
changeStreamsDirectPlay(
|
||||||
|
|
@ -639,18 +661,17 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.i(
|
Timber.i(
|
||||||
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
|
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s",
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
enableDirectPlay,
|
enableDirectPlay,
|
||||||
enableDirectStream,
|
enableDirectStream,
|
||||||
positionMs,
|
positionMs,
|
||||||
|
maxBitrate,
|
||||||
)
|
)
|
||||||
|
_state.update { it.copy(maxBitrate = maxBitrate) }
|
||||||
|
|
||||||
val maxBitrate =
|
|
||||||
preferences.appPreferences.playbackPreferences.maxBitrate
|
|
||||||
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
|
|
||||||
val response by
|
val response by
|
||||||
api.mediaInfoApi
|
api.mediaInfoApi
|
||||||
.getPostedPlaybackInfo(
|
.getPostedPlaybackInfo(
|
||||||
|
|
@ -696,6 +717,7 @@ class PlaybackViewModel
|
||||||
api.videosApi.getVideoStreamUrl(
|
api.videosApi.getVideoStreamUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
mediaSourceId = source.id,
|
mediaSourceId = source.id,
|
||||||
|
liveStreamId = source.liveStreamId,
|
||||||
static = true,
|
static = true,
|
||||||
tag = source.eTag,
|
tag = source.eTag,
|
||||||
playSessionId = response.playSessionId,
|
playSessionId = response.playSessionId,
|
||||||
|
|
@ -955,7 +977,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = currentPlayback.mediaSourceInfo,
|
source = currentPlayback.mediaSourceInfo,
|
||||||
audioStreamIndex = index,
|
audioStreamIndex = index,
|
||||||
seriesId = currentItem.item.data.seriesId,
|
item = currentItem.item.data,
|
||||||
subtitleIndex = currentPlayback.subtitleIndex,
|
subtitleIndex = currentPlayback.subtitleIndex,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -991,7 +1013,7 @@ class PlaybackViewModel
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
source = currentPlayback.mediaSourceInfo,
|
source = currentPlayback.mediaSourceInfo,
|
||||||
audioStreamIndex = currentPlayback.audioIndex,
|
audioStreamIndex = currentPlayback.audioIndex,
|
||||||
seriesId = currentItem.item.data.seriesId,
|
item = currentItem.item.data,
|
||||||
subtitleIndex = index,
|
subtitleIndex = index,
|
||||||
prefs = preferences,
|
prefs = preferences,
|
||||||
)
|
)
|
||||||
|
|
@ -1009,6 +1031,26 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeMaxBitrate(newMaxBitrate: Long) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val currentPlayback = state.value.currentPlayback
|
||||||
|
if (currentPlayback != null) {
|
||||||
|
Timber.d("Changing bitrate to %s", newMaxBitrate)
|
||||||
|
changeStreams(
|
||||||
|
item = currentItem.item,
|
||||||
|
sourceId = currentPlayback.mediaSourceInfo.id,
|
||||||
|
audioIndex = currentPlayback.audioIndex,
|
||||||
|
subtitleIndex = currentPlayback.subtitleIndex,
|
||||||
|
positionMs = onMain { player.currentPosition },
|
||||||
|
enableDirectPlay = true,
|
||||||
|
maxBitrate = newMaxBitrate,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w("Trying to change bitrate, but currentPlayback is null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun saveTrackSelection(
|
internal suspend fun saveTrackSelection(
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
type: MediaStreamType,
|
type: MediaStreamType,
|
||||||
|
|
@ -1110,7 +1152,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variables for tracking segment state
|
// Variables for tracking segment state
|
||||||
// Exposed for testing
|
@VisibleForTesting
|
||||||
internal var segmentJob: Job? = null
|
internal var segmentJob: Job? = null
|
||||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||||
private val outroShownSegments = mutableSetOf<UUID>()
|
private val outroShownSegments = mutableSetOf<UUID>()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -122,6 +123,10 @@ sealed interface PlaybackAction {
|
||||||
data object Previous : PlaybackAction
|
data object Previous : PlaybackAction
|
||||||
|
|
||||||
data object Next : PlaybackAction
|
data object Next : PlaybackAction
|
||||||
|
|
||||||
|
data class ChangeBitrate(
|
||||||
|
val maxBitrate: Long,
|
||||||
|
) : PlaybackAction
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -561,12 +566,19 @@ fun <T> BottomDialog(
|
||||||
gravity: Int,
|
gravity: Int,
|
||||||
currentChoice: BottomDialogItem<T>? = null,
|
currentChoice: BottomDialogItem<T>? = 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 focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
|
||||||
|
val bringIntoViewRequesters =
|
||||||
|
remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
|
||||||
if (currentChoice != null) {
|
if (currentChoice != null) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
choices.indexOfFirstOrNull { it == currentChoice }?.let {
|
bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
|
||||||
focusRequesters.getOrNull(it)?.tryRequestFocus()
|
focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO enforcing a width ends up ignore the gravity
|
// TODO enforcing a width ends up ignore the gravity
|
||||||
|
|
@ -591,6 +603,7 @@ fun <T> BottomDialog(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -624,7 +637,10 @@ fun <T> BottomDialog(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequesters[index])
|
||||||
|
.focusRequester(focusRequesters[index]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
package com.github.damontecres.wholphin.ui.preferences
|
package com.github.damontecres.wholphin.ui.preferences
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
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 com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
@ -38,9 +45,53 @@ enum class PreferenceScreenOption {
|
||||||
SCREENSAVER,
|
SCREENSAVER,
|
||||||
SKIP_SEGMENTS,
|
SKIP_SEGMENTS,
|
||||||
EXPERIMENTAL,
|
EXPERIMENTAL,
|
||||||
|
HDR_FORMATS,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
|
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||||
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
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.MpvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||||
|
|
@ -74,9 +77,11 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
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.ReleaseNotes
|
||||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
|
||||||
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
@ -98,6 +103,7 @@ fun PreferencesContent(
|
||||||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val firstFocusRequester = remember { FocusRequester() }
|
val firstFocusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -110,6 +116,11 @@ fun PreferencesContent(
|
||||||
var showPinFlow by remember { mutableStateOf(false) }
|
var showPinFlow by remember { mutableStateOf(false) }
|
||||||
var showVersionDialog by remember { mutableStateOf(false) }
|
var showVersionDialog by remember { mutableStateOf(false) }
|
||||||
val players by viewModel.externalPlayers.collectAsState()
|
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)) }
|
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||||
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
||||||
|
|
@ -155,6 +166,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||||
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
|
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
|
||||||
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
|
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
|
||||||
|
PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences
|
||||||
}
|
}
|
||||||
val screenTitle =
|
val screenTitle =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -165,6 +177,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||||
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
|
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
|
||||||
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
|
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
|
||||||
|
PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats
|
||||||
}
|
}
|
||||||
|
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
@ -224,6 +237,19 @@ fun PreferencesContent(
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
serverVersionMessage?.let { message ->
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
enabled = true,
|
||||||
|
onClick = {},
|
||||||
|
headlineContent = {
|
||||||
|
PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showUpdate) {
|
if (showUpdate) {
|
||||||
item {
|
item {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
@ -259,6 +285,7 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefList.forEachIndexed { groupIndex, group ->
|
prefList.forEachIndexed { groupIndex, group ->
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -475,10 +502,11 @@ fun PreferencesContent(
|
||||||
title = stringResource(pref.title),
|
title = stringResource(pref.title),
|
||||||
onClick = {
|
onClick = {
|
||||||
seerrDialogMode =
|
seerrDialogMode =
|
||||||
when (val conn = seerrConnection) {
|
when (val conn = seerrConnection.connection) {
|
||||||
is SeerrConnectionStatus.Error -> {
|
is SeerrConnectionStatus.Error -> {
|
||||||
SeerrDialogMode.Error(
|
SeerrDialogMode.Error(
|
||||||
conn.server.url,
|
conn.server,
|
||||||
|
conn.user,
|
||||||
conn.ex,
|
conn.ex,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -489,22 +517,20 @@ fun PreferencesContent(
|
||||||
|
|
||||||
is SeerrConnectionStatus.Success -> {
|
is SeerrConnectionStatus.Success -> {
|
||||||
SeerrDialogMode.Remove(
|
SeerrDialogMode.Remove(
|
||||||
conn.current.server.url,
|
conn.current,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = focusModifier,
|
modifier = focusModifier,
|
||||||
summary =
|
summary =
|
||||||
when (seerrConnection) {
|
when (seerrConnection.connection) {
|
||||||
is SeerrConnectionStatus.Error -> {
|
is SeerrConnectionStatus.Error -> {
|
||||||
stringResource(R.string.voice_error_server)
|
stringResource(R.string.voice_error_server)
|
||||||
}
|
}
|
||||||
|
|
||||||
SeerrConnectionStatus.NotConfigured -> {
|
SeerrConnectionStatus.NotConfigured -> {
|
||||||
stringResource(
|
stringResource(R.string.add_server)
|
||||||
R.string.add_server,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is SeerrConnectionStatus.Success -> {
|
is SeerrConnectionStatus.Success -> {
|
||||||
|
|
@ -699,64 +725,15 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (val mode = seerrDialogMode) {
|
|
||||||
is SeerrDialogMode.Remove -> {
|
|
||||||
ConfirmDialog(
|
|
||||||
title = stringResource(R.string.remove_seerr_server),
|
|
||||||
body = mode.serverUrl,
|
|
||||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
|
||||||
onConfirm = {
|
|
||||||
seerrVm.removeServer()
|
|
||||||
seerrDialogMode = SeerrDialogMode.None
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SeerrDialogMode.Add -> {
|
|
||||||
val currentUser by seerrVm.currentUser.collectAsState(null)
|
|
||||||
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
|
||||||
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
|
||||||
LaunchedEffect(status) {
|
|
||||||
if (status == LoadingState.Success) {
|
|
||||||
Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
|
|
||||||
seerrDialogMode = SeerrDialogMode.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AddSeerServerDialog(
|
|
||||||
currentUsername = currentUser?.name,
|
|
||||||
status = status,
|
|
||||||
onSubmit = seerrVm::submitServer,
|
|
||||||
onResetStatus = seerrVm::resetStatus,
|
|
||||||
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SeerrDialogMode.Error -> {
|
|
||||||
val errorStr = stringResource(R.string.voice_error_server)
|
|
||||||
val body =
|
|
||||||
remember(mode) {
|
|
||||||
"""
|
|
||||||
${mode.serverUrl}
|
|
||||||
|
|
||||||
$errorStr: ${mode.ex.localizedMessage}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
ConfirmDialog(
|
|
||||||
title = stringResource(R.string.remove_seerr_server),
|
|
||||||
body = body,
|
|
||||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
|
||||||
onConfirm = {
|
|
||||||
seerrVm.removeServer()
|
|
||||||
seerrDialogMode = SeerrDialogMode.None
|
|
||||||
},
|
|
||||||
bodyColor = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SeerrDialogMode.None -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SeerrDialog(
|
||||||
|
seerrDialogMode = seerrDialogMode,
|
||||||
|
seerrConnection = seerrConnection,
|
||||||
|
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
|
||||||
|
seerrVm = seerrVm,
|
||||||
|
)
|
||||||
|
|
||||||
if (showQuickConnectDialog) {
|
if (showQuickConnectDialog) {
|
||||||
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
|
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
|
||||||
val successMessage = stringResource(R.string.quick_connect_success)
|
val successMessage = stringResource(R.string.quick_connect_success)
|
||||||
|
|
@ -838,6 +815,7 @@ fun PreferencesPage(
|
||||||
PreferenceScreenOption.SCREENSAVER,
|
PreferenceScreenOption.SCREENSAVER,
|
||||||
PreferenceScreenOption.SKIP_SEGMENTS,
|
PreferenceScreenOption.SKIP_SEGMENTS,
|
||||||
PreferenceScreenOption.EXPERIMENTAL,
|
PreferenceScreenOption.EXPERIMENTAL,
|
||||||
|
PreferenceScreenOption.HDR_FORMATS,
|
||||||
-> {
|
-> {
|
||||||
PreferencesContent(
|
PreferencesContent(
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
|
|
@ -857,18 +835,3 @@ data class CacheUsage(
|
||||||
val imageMemoryMax: Long,
|
val imageMemoryMax: Long,
|
||||||
val imageDiskUsed: 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
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.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
import com.github.damontecres.wholphin.services.ServerReportService
|
import com.github.damontecres.wholphin.services.ServerReportService
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
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.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
|
@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -76,6 +79,23 @@ class PreferencesViewModel
|
||||||
|
|
||||||
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList())
|
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(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 {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val fakeIntent =
|
val fakeIntent =
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
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.Cards
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
|
@ -177,7 +178,12 @@ fun ServerIconCard(
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
|
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
|
||||||
tint = MaterialTheme.colorScheme.errorContainer,
|
tint =
|
||||||
|
if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
|
||||||
|
AppColors.GoldenYellow
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
},
|
||||||
modifier = Modifier.size(cardSize * 0.3f),
|
modifier = Modifier.size(cardSize * 0.3f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -66,11 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
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.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -101,6 +103,8 @@ private fun SwitchServerContentInternal(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
|
val focusRequesters =
|
||||||
|
remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } }
|
||||||
var showAddServer by remember { mutableStateOf(false) }
|
var showAddServer by remember { mutableStateOf(false) }
|
||||||
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
|
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
|
||||||
var focusedIndex by rememberInt(0)
|
var focusedIndex by rememberInt(0)
|
||||||
|
|
@ -144,10 +148,13 @@ private fun SwitchServerContentInternal(
|
||||||
val firstServerFocus = remember { FocusRequester() }
|
val firstServerFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(state.loading) {
|
LaunchedEffect(state.loading) {
|
||||||
val state = state
|
val state = state
|
||||||
if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) {
|
when (state.loading) {
|
||||||
firstServerFocus.tryRequestFocus()
|
is LoadingState.Error,
|
||||||
} else if (state.loading == LoadingState.Pending) {
|
LoadingState.Loading,
|
||||||
firstServerFocus.tryRequestFocus()
|
LoadingState.Pending,
|
||||||
|
-> Unit
|
||||||
|
|
||||||
|
LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
|
@ -188,10 +195,7 @@ private fun SwitchServerContentInternal(
|
||||||
Modifier
|
Modifier
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) focusedIndex = index
|
if (it.isFocused) focusedIndex = index
|
||||||
}.ifElse(
|
}.focusRequester(focusRequesters[index]),
|
||||||
index == 0,
|
|
||||||
Modifier.focusRequester(firstServerFocus),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Add Server card - always rightmost
|
// Add Server card - always rightmost
|
||||||
|
|
@ -202,10 +206,7 @@ private fun SwitchServerContentInternal(
|
||||||
Modifier
|
Modifier
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) focusedIndex = -1
|
if (it.isFocused) focusedIndex = -1
|
||||||
}.ifElse(
|
}.focusRequester(focusRequesters.last()),
|
||||||
state.servers.isEmpty(),
|
|
||||||
Modifier.focusRequester(firstServerFocus),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,20 +236,7 @@ private fun SwitchServerContentInternal(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter),
|
.align(Alignment.BottomCenter),
|
||||||
) { message ->
|
) { 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
|
// Delete server dialog
|
||||||
|
|
@ -327,12 +315,12 @@ private fun AddServerDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
|
val buttonFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
// Default focus to first discovered server if available
|
// Default focus to first discovered server if available
|
||||||
LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) {
|
LaunchedEffect(showEnterAddress) {
|
||||||
if (!showEnterAddress && state.discoveredServers.isNotEmpty()) {
|
if (!showEnterAddress) {
|
||||||
firstDiscoveredServerFocusRequester.tryRequestFocus()
|
buttonFocusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,12 +367,8 @@ private fun AddServerDialog(
|
||||||
key = { state.discoveredServers[it].url },
|
key = { state.discoveredServers[it].url },
|
||||||
) { index ->
|
) { index ->
|
||||||
val server = state.discoveredServers[index]
|
val server = state.discoveredServers[index]
|
||||||
val focusRequester =
|
val focusRequester = remember { FocusRequester() }
|
||||||
if (index == 0) {
|
LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() }
|
||||||
firstDiscoveredServerFocusRequester
|
|
||||||
} else {
|
|
||||||
remember { FocusRequester() }
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
|
@ -491,21 +475,38 @@ fun serverErrorMessage(
|
||||||
server: ServerState?,
|
server: ServerState?,
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
): String? =
|
): String? =
|
||||||
when {
|
if (server == null || server.status == ServerConnectionStatus.Pending) {
|
||||||
server?.status is ServerConnectionStatus.Error -> {
|
null
|
||||||
server.status.message
|
} else if (server.status is ServerConnectionStatus.Success) {
|
||||||
}
|
resources.serverSupportedMessage(
|
||||||
|
server.versionSupported,
|
||||||
server?.status is ServerConnectionStatus.Success &&
|
server.status.systemInfo.version
|
||||||
server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
|
?.let { ServerVersion.fromString(it) },
|
||||||
resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}"
|
)
|
||||||
}
|
} else if (server.status is ServerConnectionStatus.Error) {
|
||||||
|
server.status.message
|
||||||
server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
|
} else {
|
||||||
resources.getString(R.string.server_version_not_supported)
|
resources.serverSupportedMessage(
|
||||||
}
|
server.versionSupported,
|
||||||
|
null,
|
||||||
else -> {
|
)
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.SwitchErrorMessage(message: String?) {
|
||||||
|
message?.let {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ class SwitchServerViewModel
|
||||||
result.systemInfo.version?.let { ServerVersion.fromString(it) }
|
result.systemInfo.version?.let { ServerVersion.fromString(it) }
|
||||||
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
|
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
|
||||||
ServerVersionSupported.NOT_SUPPORTED
|
ServerVersionSupported.NOT_SUPPORTED
|
||||||
|
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
|
||||||
|
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
|
||||||
} else {
|
} else {
|
||||||
ServerVersionSupported.SUPPORTED
|
ServerVersionSupported.SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
@ -307,4 +309,5 @@ enum class ServerVersionSupported {
|
||||||
SUPPORTED,
|
SUPPORTED,
|
||||||
NOT_SUPPORTED,
|
NOT_SUPPORTED,
|
||||||
UNKNOWN,
|
UNKNOWN,
|
||||||
|
NOT_SUPPORTED_IN_FUTURE,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
|
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
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.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -183,37 +183,15 @@ fun SwitchUserContent(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (state.serverVersionSupported) {
|
val resources = LocalResources.current
|
||||||
ServerVersionSupported.SUPPORTED -> {}
|
val message =
|
||||||
|
remember(resources, state.serverVersionSupported) {
|
||||||
ServerVersionSupported.NOT_SUPPORTED,
|
resources.serverSupportedMessage(
|
||||||
ServerVersionSupported.UNKNOWN,
|
state.serverVersionSupported,
|
||||||
-> {
|
state.serverVersion,
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue