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:
|
||||
# Setup the SDKs
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: '21'
|
||||
|
|
|
|||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
echo "SHA256 checksums:"
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||
- name: Upload mapping files
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: mapping
|
||||
path: |
|
||||
|
|
|
|||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Run pre-commit
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -59,14 +59,14 @@ jobs:
|
|||
echo "SHA256 checksums:"
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||
- name: Upload AAB
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: AAB
|
||||
path: |
|
||||
app/build/outputs/bundle/**/*.aab
|
||||
compression-level: 0
|
||||
- name: Upload mapping files
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: mapping
|
||||
path: |
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ dependencies {
|
|||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
implementation(libs.ass.media)
|
||||
implementation(libs.exoplayerhdrutils)
|
||||
|
||||
implementation(libs.coil.core)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -22,3 +22,6 @@
|
|||
-keep class androidx.media3.ui.SubtitlePainter {
|
||||
private final float outlineWidth;
|
||||
}
|
||||
|
||||
# Keep external/native functions
|
||||
-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
<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>
|
||||
|
|
|
|||
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:maxSdkVersion="28" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
<uses-permission android:name="com.amazon.permission.media.session.voicecommandcontrol" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import android.view.WindowManager
|
|||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -77,7 +78,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -86,7 +86,6 @@ import kotlinx.coroutines.sync.withLock
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -444,120 +443,27 @@ class MainActivityViewModel
|
|||
viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
try {
|
||||
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@withLock
|
||||
}
|
||||
|
||||
is IntentResult.Target -> {
|
||||
val current = serverRepository.current.value
|
||||
if (current != null) {
|
||||
Timber.i("Received valid intent, switching to AppContent")
|
||||
|
||||
if (result.addHomeToBackStack) {
|
||||
navigationManager.reloadHome()
|
||||
} else {
|
||||
navigationManager.backStack.clear()
|
||||
}
|
||||
navigationManager.backStack.addAll(result.destinations)
|
||||
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(current),
|
||||
handleAppUpgrade()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during app upgrade!")
|
||||
showToast(
|
||||
context,
|
||||
"Error during app upgrade: ${ex.localizedMessage}",
|
||||
)
|
||||
} else {
|
||||
// This should never happen, but just reset app state if it does
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
Timber.e("Error parsing intent, no user is active")
|
||||
showToast(context, "An error occurred parsing the intent")
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
IntentResult.NoOp,
|
||||
null,
|
||||
-> {
|
||||
// No-op, proceed below
|
||||
}
|
||||
try {
|
||||
val result = handleIntent(intent)
|
||||
if (result) {
|
||||
Timber.i("appStart was handled by intent")
|
||||
return@withLock
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error parsing intent")
|
||||
}
|
||||
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val profileProtected =
|
||||
serverRepository.current.value
|
||||
?.user
|
||||
?.isProtected == true
|
||||
if (prefs.signInAutomatically && !profileProtected) {
|
||||
val current =
|
||||
serverRepository.restoreSession(
|
||||
prefs.currentServerId?.toUUIDOrNull(),
|
||||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
)
|
||||
if (current != null) {
|
||||
if (current.user.isProtected) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
current.server,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Restored
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(
|
||||
current,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Did not restore
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
if (currentServer != null) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
currentServer,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
handleAppStart()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during appStart")
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
|
|
@ -582,6 +488,103 @@ class MainActivityViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an [Intent], navigating if needed
|
||||
*
|
||||
* @return true if the intent was handled, false if normal app start should proceed
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleIntent(intent: Intent?): Boolean {
|
||||
val result = intent?.let { intentService.parseIntent(intent) }
|
||||
when (result) {
|
||||
is IntentResult.Error -> {
|
||||
val current = serverRepository.current.value
|
||||
val destination =
|
||||
if (current != null) {
|
||||
SetupDestination.UserList(current.server)
|
||||
} else {
|
||||
SetupDestination.ServerList
|
||||
}
|
||||
setupNavigationManager.navigateTo(destination)
|
||||
Timber.e("Error parsing intent: %s", result.message)
|
||||
showToast(context, result.message)
|
||||
return true
|
||||
}
|
||||
|
||||
is IntentResult.Target -> {
|
||||
val current = serverRepository.current.value
|
||||
if (current != null) {
|
||||
Timber.i("Received valid intent, switching to AppContent")
|
||||
|
||||
if (result.addHomeToBackStack) {
|
||||
navigationManager.reloadHome()
|
||||
} else {
|
||||
navigationManager.backStack.clear()
|
||||
}
|
||||
navigationManager.backStack.addAll(result.destinations)
|
||||
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(current),
|
||||
)
|
||||
} else {
|
||||
// This should never happen, but just reset app state if it does
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
Timber.e("Error parsing intent, no user is active")
|
||||
showToast(context, "An error occurred parsing the intent")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
IntentResult.NoOp,
|
||||
null,
|
||||
-> {
|
||||
// Not handled
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAppUpgrade() {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle starting the app including auto sign in and navigating to the right starting page
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleAppStart() {
|
||||
val signInAutomatically = preferences.data.first().signInAutomatically
|
||||
val currentUser = serverRepository.current.value
|
||||
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
|
||||
// Hot reload
|
||||
Timber.v("Hot reload restoring state")
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(currentUser),
|
||||
)
|
||||
} else {
|
||||
val restoredSession =
|
||||
if (signInAutomatically) {
|
||||
Timber.v("Sign in automatically")
|
||||
serverRepository.restoreLastSession()
|
||||
} else {
|
||||
serverRepository.getMostRecentServer()
|
||||
}
|
||||
Timber.d("Restore last session result=%s", restoredSession)
|
||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Destination?.isPlayback: Boolean
|
||||
|
|
|
|||
|
|
@ -122,5 +122,7 @@ class WholphinApplication :
|
|||
private set
|
||||
|
||||
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.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
|
|
@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -75,8 +74,10 @@ class WholphinDreamService :
|
|||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
lifecycleScope.launchDefault {
|
||||
if (serverRepository.current.value == null) {
|
||||
val prefs = preferencesDataStore.data.first()
|
||||
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("Could not restore user session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
|||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
|
|
@ -48,8 +49,9 @@ import java.util.UUID
|
|||
SeerrServer::class,
|
||||
SeerrUser::class,
|
||||
RememberedTab::class,
|
||||
SeriesTrackChoice::class,
|
||||
],
|
||||
version = 35,
|
||||
version = 41,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -68,6 +70,8 @@ import java.util.UUID
|
|||
AutoMigration(32, 33),
|
||||
AutoMigration(33, 34),
|
||||
AutoMigration(34, 35),
|
||||
AutoMigration(35, 40),
|
||||
AutoMigration(40, 41),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -87,6 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||
|
||||
abstract fun rememberedTabDao(): RememberedTabDao
|
||||
|
||||
abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceResult
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -28,7 +31,7 @@ class ItemPlaybackRepository
|
|||
constructor(
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||
private val streamChoiceService: StreamChoiceService,
|
||||
) {
|
||||
/**
|
||||
|
|
@ -45,54 +48,79 @@ class ItemPlaybackRepository
|
|||
): ChosenStreams? =
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
||||
return getChosenItemFromPlayback(item, itemPlayback, prefs)
|
||||
}
|
||||
|
||||
suspend fun getChosenItemFromPlayback(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback?,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? {
|
||||
val audioStc =
|
||||
streamChoiceService.getSeriesTrackChoices(
|
||||
item.data,
|
||||
SeriesTrackChoiceType.AUDIO,
|
||||
)
|
||||
val subtitleStc =
|
||||
streamChoiceService.getSeriesTrackChoices(
|
||||
item.data,
|
||||
SeriesTrackChoiceType.SUBTITLE,
|
||||
)
|
||||
return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chosen source & stream for the given item
|
||||
*
|
||||
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
|
||||
* This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
|
||||
*/
|
||||
fun getChosenItemFromPlayback(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback?,
|
||||
plc: PlaybackLanguageChoice?,
|
||||
audioStc: List<SeriesTrackChoice>,
|
||||
subtitleStc: List<SeriesTrackChoice>,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? {
|
||||
Timber.v(
|
||||
"For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
|
||||
item.id,
|
||||
itemPlayback != null,
|
||||
audioStc.isNotEmpty(),
|
||||
subtitleStc.isNotEmpty(),
|
||||
)
|
||||
val source =
|
||||
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
|
||||
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
|
||||
if (source != null) {
|
||||
val audioStream =
|
||||
val audioResult =
|
||||
streamChoiceService.chooseAudioStream(
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.AUDIO }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
stc = audioStc,
|
||||
prefs = prefs,
|
||||
)
|
||||
val subtitleStream =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = audioStream?.language,
|
||||
audioStreamLang = audioResult.stream?.language,
|
||||
candidates =
|
||||
source.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.orEmpty(),
|
||||
itemPlayback = itemPlayback,
|
||||
playbackLanguageChoice = plc,
|
||||
stc = subtitleStc,
|
||||
prefs = prefs,
|
||||
)
|
||||
return ChosenStreams(
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
audioStc = audioStc,
|
||||
subtitleStc = subtitleStc,
|
||||
itemId = item.id,
|
||||
source = source,
|
||||
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
|
||||
audioStream = audioStream,
|
||||
audioStream = audioResult,
|
||||
subtitleStream = subtitleStream,
|
||||
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
|
||||
)
|
||||
|
|
@ -156,26 +184,36 @@ class ItemPlaybackRepository
|
|||
Timber.v("Saving track selection %s", toSave)
|
||||
toSave = saveItemPlayback(toSave)
|
||||
val seriesId = item.data.seriesId
|
||||
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
||||
if (seriesId != null || item.data.parentId != null) {
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
val stream =
|
||||
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateAudio(item.data, stream.language!!)
|
||||
streamChoiceService.saveSeriesTrackChoice(
|
||||
item.data,
|
||||
stream.type,
|
||||
stream,
|
||||
)
|
||||
}
|
||||
} else if (type == MediaStreamType.SUBTITLE) {
|
||||
if (trackIndex == TrackIndex.DISABLED) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
streamChoiceService.saveDisabledSeriesTrackChoice(
|
||||
item.data,
|
||||
subtitleLang = null,
|
||||
subtitlesDisabled = true,
|
||||
MediaStreamType.SUBTITLE,
|
||||
)
|
||||
} else if (trackIndex == TrackIndex.ONLY_FORCED) {
|
||||
streamChoiceService.saveOnlyForcedSeriesTrackChoice(
|
||||
item.data,
|
||||
MediaStreamType.SUBTITLE,
|
||||
)
|
||||
} else {
|
||||
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
||||
val stream =
|
||||
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
streamChoiceService.saveSeriesTrackChoice(
|
||||
item.data,
|
||||
stream.language!!,
|
||||
subtitlesDisabled = false,
|
||||
stream.type,
|
||||
stream,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -240,9 +278,10 @@ class ItemPlaybackRepository
|
|||
*/
|
||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||
chosenStreams?.plc?.let {
|
||||
Timber.d("Deleting %s", it)
|
||||
playbackLanguageChoiceDao.delete(it)
|
||||
val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
|
||||
if (stc.isNotEmpty()) {
|
||||
Timber.d("Deleting %s", stc)
|
||||
seriesTrackChoiceDao.delete(stc)
|
||||
}
|
||||
chosenStreams?.itemPlayback?.let {
|
||||
Timber.d("Deleting %s", it)
|
||||
|
|
@ -256,11 +295,12 @@ class ItemPlaybackRepository
|
|||
*/
|
||||
data class ChosenStreams(
|
||||
val itemPlayback: ItemPlayback?,
|
||||
val plc: PlaybackLanguageChoice?,
|
||||
val audioStc: List<SeriesTrackChoice>,
|
||||
val subtitleStc: List<SeriesTrackChoice>,
|
||||
val itemId: UUID,
|
||||
val source: MediaSourceInfo,
|
||||
val videoStream: MediaStream?,
|
||||
val audioStream: MediaStream?,
|
||||
val subtitleStream: MediaStream?,
|
||||
val audioStream: StreamChoiceResult,
|
||||
val subtitleStream: StreamChoiceResult,
|
||||
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 java.util.UUID
|
||||
|
||||
@Deprecated("Use SeriesTrackChoiceDao")
|
||||
@Dao
|
||||
interface PlaybackLanguageChoiceDao {
|
||||
@Deprecated("Use SeriesTrackChoiceDao")
|
||||
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
|
||||
suspend fun get(
|
||||
userId: Int,
|
||||
seriesId: UUID,
|
||||
): PlaybackLanguageChoice?
|
||||
|
||||
@Deprecated("Use SeriesTrackChoiceDao")
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(plc: PlaybackLanguageChoice): Long
|
||||
|
||||
@Deprecated("Use SeriesTrackChoiceDao")
|
||||
@Delete
|
||||
fun delete(plc: PlaybackLanguageChoice)
|
||||
|
||||
@Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
|
||||
suspend fun getAll(
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): List<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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
|
@ -49,8 +44,8 @@ class ServerRepository
|
|||
val jellyfin: Jellyfin,
|
||||
val serverDao: JellyfinServerDao,
|
||||
val apiClient: ApiClient,
|
||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||
) {
|
||||
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||
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
|
||||
*
|
||||
|
|
@ -88,10 +85,11 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||
_current.value = null
|
||||
mostRecentServerProvider.clearUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user
|
||||
* Saves the server & user to the app database and updates the [ApiClient] to use this server & user
|
||||
*/
|
||||
suspend fun changeUser(
|
||||
server: JellyfinServer,
|
||||
|
|
@ -119,70 +117,101 @@ class ServerRepository
|
|||
)
|
||||
serverDao.addOrUpdateServer(updatedServer)
|
||||
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = updatedServer.id.toServerString()
|
||||
currentUserId = updatedUser.id.toServerString()
|
||||
}.build()
|
||||
}
|
||||
|
||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
mostRecentServerProvider.save(currentUser)
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = ServerUserConfig(userDto)
|
||||
}
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
return@withContext currentUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a session for the given server & user such as when the app reopens
|
||||
* Try to restore the most recent session
|
||||
*
|
||||
* If user has a PIN, this returns false
|
||||
* Does not check if auto sign-in is enabled, but does check if the user profile is protected
|
||||
*/
|
||||
suspend fun restoreSession(
|
||||
suspend fun restoreLastSession(): RestoredSession {
|
||||
val recentServer = mostRecentServerProvider.get()
|
||||
Timber.d("restoreLastSession: recentServer=%s", recentServer)
|
||||
val result =
|
||||
when (recentServer) {
|
||||
MostRecentServer.None -> {
|
||||
RestoredSession.None
|
||||
}
|
||||
|
||||
is MostRecentServer.Server -> {
|
||||
val server = serverDao.getServer(recentServer.serverId)
|
||||
if (server != null) {
|
||||
RestoredSession.ServerOnly(server.server)
|
||||
} else {
|
||||
RestoredSession.None
|
||||
}
|
||||
}
|
||||
|
||||
is MostRecentServer.ServerAndUser -> {
|
||||
val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
|
||||
if (currentUser != null) {
|
||||
if (currentUser.user.isProtected) {
|
||||
RestoredSession.ServerOnly(currentUser.server)
|
||||
} else {
|
||||
RestoredSession.Success(currentUser)
|
||||
}
|
||||
} else {
|
||||
getMostRecentServerInternal(recentServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently used server, if any
|
||||
*/
|
||||
suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
|
||||
|
||||
suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
|
||||
val recentServer = recentServer ?: mostRecentServerProvider.get()
|
||||
val serverId =
|
||||
when (recentServer) {
|
||||
MostRecentServer.None -> null
|
||||
is MostRecentServer.Server -> recentServer.serverId
|
||||
is MostRecentServer.ServerAndUser -> recentServer.serverId
|
||||
}
|
||||
val server = serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
return if (server != null) {
|
||||
RestoredSession.ServerOnly(server)
|
||||
} else {
|
||||
RestoredSession.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to change to the given server & user. If the user's profile is protected, returns null
|
||||
*
|
||||
* @return the server/user or null if the session could not be restored
|
||||
*/
|
||||
suspend fun tryChangeUser(
|
||||
serverId: UUID?,
|
||||
userId: UUID?,
|
||||
): CurrentUser? {
|
||||
if (serverId == null || userId == null) {
|
||||
_current.value = null
|
||||
return null
|
||||
}
|
||||
val serverAndUsers =
|
||||
): CurrentUser? =
|
||||
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
|
||||
return@withContext if (serverId == null || userId == null) {
|
||||
_current.value = null
|
||||
null
|
||||
} else {
|
||||
val serverAndUsers = serverDao.getServer(serverId)
|
||||
if (serverAndUsers != null) {
|
||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||
if (user != null) {
|
||||
return changeUser(serverAndUsers.server, user)
|
||||
if (user != null && !user.isProtected) {
|
||||
changeUser(serverAndUsers.server, user)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
||||
withContext(ioDispatcher) {
|
||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
}
|
||||
|
||||
fun closeSession() {
|
||||
_current.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a successful [AuthenticationResult], switch to the user that just authenticated
|
||||
|
|
@ -244,13 +273,7 @@ class ServerRepository
|
|||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clearUser()
|
||||
apiClient.update(accessToken = null)
|
||||
}
|
||||
withContext(ioDispatcher) {
|
||||
|
|
@ -263,14 +286,7 @@ class ServerRepository
|
|||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clear()
|
||||
apiClient.update(baseUrl = null, accessToken = null)
|
||||
}
|
||||
withContext(ioDispatcher) {
|
||||
|
|
@ -279,14 +295,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun switchServerOrUser() {
|
||||
userPreferencesDataStore.updateData {
|
||||
it
|
||||
.toBuilder()
|
||||
.apply {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}.build()
|
||||
}
|
||||
mostRecentServerProvider.clear()
|
||||
}
|
||||
|
||||
suspend fun updateUserAuth(
|
||||
|
|
@ -335,17 +344,6 @@ class ServerRepository
|
|||
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(
|
||||
"${context.packageName}_server",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
const val SERVER_URL_KEY = "current.server"
|
||||
const val ACCESS_TOKEN_KEY = "current.accessToken"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ data class BaseItem(
|
|||
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
|
||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
|
||||
BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ")
|
||||
else -> data.productionYear?.toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.VideoType
|
||||
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
|
|
@ -155,6 +156,22 @@ data class GetItemsFilter(
|
|||
},
|
||||
)
|
||||
|
||||
fun applyTo(req: GetAlbumArtistsRequest) =
|
||||
req.copy(
|
||||
minCommunityRating = minCommunityRating,
|
||||
isFavorite = favorite,
|
||||
genreIds = genres,
|
||||
personIds = persons,
|
||||
studioIds = studios,
|
||||
tags = tags,
|
||||
officialRatings = officialRatings,
|
||||
years =
|
||||
buildSet {
|
||||
years?.letNotEmpty(::addAll)
|
||||
decades?.forEach { addAll(it..<(it + 10)) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
@file:UseSerializers(
|
||||
UUIDSerializer::class,
|
||||
DateTimeSerializer::class,
|
||||
)
|
||||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
|
|
@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
|
|||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -174,18 +181,44 @@ sealed interface HomeRowConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* An arbitrary [GetItemsRequest] allowing to query for anything
|
||||
* An arbitrary [HomeGetItemsFilter] allowing to query for most anything
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("GetItems")
|
||||
data class GetItems(
|
||||
val name: String,
|
||||
val getItems: GetItemsRequest,
|
||||
val filter: HomeGetItemsFilter,
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<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
|
||||
|
|
@ -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.preferences.SubtitleModePreference
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -68,6 +69,7 @@ data class JellyfinUser(
|
|||
val lastUsed: ZonedDateTime? = null,
|
||||
val uiLanguage: String? = null,
|
||||
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||
@Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
|
||||
) {
|
||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||
|
||||
|
|
@ -77,9 +79,17 @@ data class JellyfinUser(
|
|||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
|
||||
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
|
||||
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
|
||||
"appPreferences=$appPreferences"
|
||||
"appPreferences=$appPreferences, config=$config"
|
||||
|
||||
fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class JellyfinUserConfig(
|
||||
@ColumnInfo(defaultValue = "UNSET")
|
||||
val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ data class Person(
|
|||
name = dto.name,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
|
||||
favorite = false,
|
||||
)
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ data class Person(
|
|||
name = dto.name,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
|
||||
favorite = favorite,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
|||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* DEPRECATED
|
||||
*
|
||||
* Stores the language choices for a series so they can be applied automatically to other episodes
|
||||
* without the user needing to explicitly choose the tracks
|
||||
*
|
||||
* @see SeriesTrackChoice
|
||||
*/
|
||||
@Deprecated("Use SeriesTrackChoice")
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
|
|
|
|||
|
|
@ -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) } ||
|
||||
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.WholphinApplication
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.formatMaxBitrate
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||
|
|
@ -345,7 +346,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
|
||||
const val MEGA_BIT = 1024 * 1024L
|
||||
const val DEFAULT_BITRATE = 100 * MEGA_BIT
|
||||
private val bitrateValues =
|
||||
val bitrateValues =
|
||||
listOf(
|
||||
500 * 1024L,
|
||||
750 * 1024L,
|
||||
|
|
@ -378,11 +379,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
|
||||
if (v < MEGA_BIT) {
|
||||
"${v / 1024} kbps"
|
||||
} else {
|
||||
"${v / MEGA_BIT} Mbps"
|
||||
}
|
||||
formatMaxBitrate(v)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -456,15 +453,69 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val DirectPlayDoviProfile7 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.force_dovi_profile_7,
|
||||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
|
||||
val Hdr10Override =
|
||||
AppChoicePreference<AppPreferences, HdrOverrideMode>(
|
||||
title = R.string.hdr10_override,
|
||||
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
|
||||
getter = { it.playbackPreferences.overrides.hdr10Override },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value }
|
||||
prefs.updatePlaybackOverrides { hdr10Override = value }
|
||||
},
|
||||
summary = R.string.force_dovi_profile_7_summary,
|
||||
displayValues = R.array.hdr_override_modes,
|
||||
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
|
||||
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
|
||||
)
|
||||
|
||||
val Hdr10PlusOverride =
|
||||
AppChoicePreference<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 =
|
||||
|
|
@ -919,6 +970,12 @@ sealed interface AppPreference<Pref, T> {
|
|||
destination = Destination.Settings(PreferenceScreenOption.MPV),
|
||||
)
|
||||
|
||||
val HdrFormatSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.hdr_formats,
|
||||
destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
|
||||
)
|
||||
|
||||
val MpvHardwareDecoding =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.mpv_hardware_decoding,
|
||||
|
|
@ -1183,8 +1240,17 @@ private val ExoPlayerSettings =
|
|||
AppPreference.Ac3Supported,
|
||||
AppPreference.AssSubtitleMode,
|
||||
AppPreference.DirectPlayPgs,
|
||||
AppPreference.DirectPlayDoviProfile7,
|
||||
AppPreference.DecodeAv1,
|
||||
AppPreference.HdrFormatSettings,
|
||||
)
|
||||
|
||||
private val HdrFormatSettings =
|
||||
listOf(
|
||||
AppPreference.Hdr10Override,
|
||||
AppPreference.Hdr10PlusOverride,
|
||||
AppPreference.DoviProfile5Override,
|
||||
AppPreference.DoviProfile7Override,
|
||||
AppPreference.DoviProfile8Override,
|
||||
)
|
||||
|
||||
val ExoPlayerPreferences =
|
||||
|
|
@ -1195,6 +1261,14 @@ val ExoPlayerPreferences =
|
|||
),
|
||||
)
|
||||
|
||||
val HdrFormatPreferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.hdr_formats,
|
||||
preferences = HdrFormatSettings,
|
||||
),
|
||||
)
|
||||
|
||||
private val MpvSettings =
|
||||
listOf(
|
||||
AppPreference.MpvHardwareDecoding,
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ class AppPreferencesSerializer
|
|||
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||
preferDolbyVisionOverHdr10Plus =
|
||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
||||
convertDolbyVisionProfile7 =
|
||||
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ object ExperimentalPreference {
|
|||
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 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.prefer_ac3_for_surround,
|
||||
|
|
@ -93,6 +104,7 @@ val experimentalPreferences =
|
|||
listOf(
|
||||
ExperimentalPreference.VideoTunneling,
|
||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
||||
ExperimentalPreference.ConvertDolbyVisionProfile7,
|
||||
ExperimentalPreference.PreferAc3ForSurround,
|
||||
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.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
||||
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
|
|
@ -36,6 +42,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
|||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
|
|
@ -56,6 +63,9 @@ class AppUpgradeHandler
|
|||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
private val rememberedTabDao: RememberedTabDao,
|
||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||
) {
|
||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||
|
|
@ -442,5 +452,59 @@ class AppUpgradeHandler
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
|
||||
try {
|
||||
val prefs = appPreferences.data.firstOrNull()
|
||||
val serverId = prefs?.currentServerId?.toUUIDOrNull()
|
||||
val userId = prefs?.currentUserId?.toUUIDOrNull()
|
||||
if (serverId != null && userId != null) {
|
||||
val server = serverDao.getServer(serverId)
|
||||
val user = server?.users?.firstOrNull { it.id == userId }
|
||||
if (server != null && user != null) {
|
||||
Timber.i("Migrating most recent server")
|
||||
mostRecentServerProvider.save(CurrentUser(server.server, user))
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during migration for most recent server")
|
||||
}
|
||||
appPreferences.updateData {
|
||||
it.update {
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) {
|
||||
// directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward
|
||||
appPreferences.updateData {
|
||||
if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) {
|
||||
it.updatePlaybackOverrides {
|
||||
doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
|
||||
directPlayDolbyVisionEL = false
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
|
||||
val count = seriesTrackChoiceDao.count()
|
||||
if (count == 0) {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val plcs = playbackLanguageChoiceDao.getAll(100, offset)
|
||||
if (plcs.isEmpty()) break
|
||||
offset += plcs.size
|
||||
|
||||
val stcs = plcs.flatMap(SeriesTrackChoice::from)
|
||||
Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
|
||||
seriesTrackChoiceDao.save(stcs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import android.content.Context
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
||||
import com.github.damontecres.wholphin.preferences.HdrFormat
|
||||
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.enabled
|
||||
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
|
||||
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
||||
|
|
@ -61,9 +65,22 @@ class DeviceProfileService
|
|||
downMixAudio = newConfig.overrides.downmixStereo,
|
||||
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||
pgsDirectPlay = newConfig.overrides.directPlayPgs,
|
||||
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
||||
forceEnabledHdr =
|
||||
buildSet {
|
||||
appPrefs
|
||||
.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
|
||||
.let(::addAll)
|
||||
// A profile 7 track the player converts to 8.1 is one the server need not transcode
|
||||
if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
|
||||
HdrFormat.DOVI_PROFILE_7
|
||||
.rangeTypeNames(newConfig.jellyfinTenEleven)
|
||||
.let(::addAll)
|
||||
}
|
||||
},
|
||||
forceDisabledHdr =
|
||||
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
|
||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||
maxResolution = newConfig.overrides.maxResolution,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.HomeItemFields
|
||||
import com.github.damontecres.wholphin.ui.ProgramItemFields
|
||||
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
|
|
@ -55,32 +34,15 @@ import kotlinx.serialization.json.jsonArray
|
|||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Handles getting home page settings and data
|
||||
|
|
@ -92,21 +54,11 @@ class HomeSettingsService
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val suggestionService: SuggestionService,
|
||||
private val displayPreferencesService: DisplayPreferencesService,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowTrailingComma = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The current home page settings
|
||||
*/
|
||||
|
|
@ -188,9 +140,8 @@ class HomeSettingsService
|
|||
|
||||
/**
|
||||
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
|
||||
*
|
||||
* This is public only for testing
|
||||
*/
|
||||
@VisibleForTesting
|
||||
fun decode(element: JsonElement): HomePageSettings {
|
||||
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
|
||||
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
|
||||
|
|
@ -211,6 +162,42 @@ class HomeSettingsService
|
|||
return HomePageSettings(rows, version)
|
||||
}
|
||||
|
||||
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
|
||||
try {
|
||||
block.invoke()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading settings")
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun fetchSettingsFor(
|
||||
userId: UUID,
|
||||
source: HomePageSettingsSource,
|
||||
): HomePageResolvedSettings? {
|
||||
val (resolvedSource, settings) =
|
||||
when (source) {
|
||||
HomePageSettingsSource.UNSET -> loadInOrder(userId)
|
||||
HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId)
|
||||
HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId)
|
||||
HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings()
|
||||
HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings()
|
||||
HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings()
|
||||
}
|
||||
Timber.i(
|
||||
"fetch home settings: resolvedSource=%s, userId=%s, found=%s",
|
||||
resolvedSource,
|
||||
userId,
|
||||
settings != null,
|
||||
)
|
||||
return settings?.let {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads [HomePageSettings] into [currentSettings]
|
||||
*
|
||||
|
|
@ -218,55 +205,70 @@ class HomeSettingsService
|
|||
*
|
||||
* Does not persist either the server nor default
|
||||
*/
|
||||
suspend fun loadCurrentSettings(userId: UUID) {
|
||||
Timber.v("Getting setting for %s", userId)
|
||||
// User local then server/remote otherwise create a default
|
||||
suspend fun loadCurrentSettings(user: JellyfinUser) {
|
||||
currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
|
||||
Timber.v("Getting setting for %s", user.id)
|
||||
val settings =
|
||||
try {
|
||||
val local = loadFromLocal(userId)
|
||||
Timber.v("Found local? %s", local != null)
|
||||
local
|
||||
fetchSettingsFor(user.id, user.config.homeSettingsSource)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading local settings")
|
||||
// TODO show toast?
|
||||
null
|
||||
} ?: try {
|
||||
val remote = loadFromServer(userId)
|
||||
Timber.v("Found remote? %s", remote != null)
|
||||
remote
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error loading remote settings")
|
||||
Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
|
||||
null
|
||||
}
|
||||
val resolvedSettings =
|
||||
if (settings != null) {
|
||||
Timber.v("Found settings")
|
||||
// Resolve
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(userId, resolvedRows)
|
||||
} else {
|
||||
createDefault(userId)
|
||||
Timber.v("Found settings: %s", settings != null)
|
||||
val resolvedSettings = settings ?: createDefault(user.id)
|
||||
currentSettings.update { resolvedSettings }
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
suspend fun updateCurrent(
|
||||
userId: UUID,
|
||||
source: HomePageSettingsSource,
|
||||
settings: HomePageSettings,
|
||||
) {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
|
||||
currentSettings.update { resolvedSettings }
|
||||
currentSettings.update { it.copy(rows = resolvedRows) }
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
|
||||
serverDao.updateUser(toSave)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -316,7 +318,7 @@ class HomeSettingsService
|
|||
),
|
||||
)
|
||||
val rowConfig = continueWatchingRow + includedIds
|
||||
return HomePageResolvedSettings(userId, rowConfig)
|
||||
return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -432,7 +434,7 @@ class HomeSettingsService
|
|||
null
|
||||
}
|
||||
}.flatten()
|
||||
HomePageResolvedSettings(userId, rowConfigs)
|
||||
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -560,6 +562,14 @@ class HomeSettingsService
|
|||
config,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.CustomEndpoint -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = StringStringProvider(config.title),
|
||||
config,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getItemName(
|
||||
|
|
@ -585,631 +595,16 @@ class HomeSettingsService
|
|||
ResStringProvider(R.string.unknown)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data from the server for a given [HomeRowConfig]
|
||||
*/
|
||||
suspend fun fetchDataForRow(
|
||||
row: HomeRowConfig,
|
||||
scope: CoroutineScope,
|
||||
prefs: HomePagePreferences,
|
||||
userDto: ServerUserConfig,
|
||||
libraries: List<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 {
|
||||
const val CUSTOM_PREF_ID = "home_settings"
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val jsonParser =
|
||||
Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowTrailingComma = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1229,10 +624,14 @@ data class HomeRowConfigDisplay(
|
|||
*/
|
||||
data class HomePageResolvedSettings(
|
||||
val userId: UUID,
|
||||
val source: HomePageSettingsSource,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
) {
|
||||
fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
|
||||
companion object {
|
||||
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
|
||||
val EMPTY =
|
||||
HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1266,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
|
|||
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||
?: ResStringProvider(R.string.recently_added)
|
||||
|
||||
private val Library?.itemFields: List<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 =
|
||||
when (collectionType) {
|
||||
CollectionType.MUSIC,
|
||||
|
|
@ -1315,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
|
|||
HomeRowViewOptions()
|
||||
}
|
||||
}
|
||||
|
||||
enum class HomePageSettingsSource(
|
||||
@param:StringRes val stringResId: Int,
|
||||
) {
|
||||
UNSET(R.string.unknown),
|
||||
LOCAL(R.string.home_settings_source_local),
|
||||
SERVER_PROFILE(R.string.home_settings_source_server_profile),
|
||||
PLUGIN(R.string.home_settings_source_plugin),
|
||||
DEFAULT(R.string.home_settings_source_unset),
|
||||
WEB_CONFIG(R.string.home_settings_source_web),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ class ImageUrlService
|
|||
parentBackdropId: UUID? = null,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
seriesPrimaryTag: String? = null,
|
||||
parentLogoId: UUID? = null,
|
||||
parentLogoTag: String? = null,
|
||||
parentThumbTag: String? = null,
|
||||
parentBackdropTags: List<String> = emptyList(),
|
||||
): String? =
|
||||
when (imageType) {
|
||||
ImageType.LOGO -> {
|
||||
|
|
@ -44,6 +49,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = parentLogoTag.takeIf { parentLogoId == seriesId },
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
|
|
@ -51,6 +57,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = imageTags[imageType],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +70,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
|
||||
)
|
||||
} else if (backdropTags.isNotEmpty()) {
|
||||
getItemImageUrl(
|
||||
|
|
@ -70,6 +78,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = backdropTags.first(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
@ -86,6 +95,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = parentThumbTag,
|
||||
)
|
||||
} else if (useSeriesForPrimary && parentBackdropId != null &&
|
||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||
|
|
@ -96,6 +106,7 @@ class ImageUrlService
|
|||
imageType = ImageType.BACKDROP,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = parentBackdropTags.firstOrNull(),
|
||||
)
|
||||
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||
getItemImageUrl(
|
||||
|
|
@ -103,6 +114,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = parentThumbTag,
|
||||
)
|
||||
} else if (useSeriesForPrimary &&
|
||||
parentThumbId == null &&
|
||||
|
|
@ -115,6 +127,7 @@ class ImageUrlService
|
|||
imageType = ImageType.PRIMARY,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = imageTags[ImageType.PRIMARY],
|
||||
)
|
||||
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
|
||||
// If no thumb, use backdrop if available
|
||||
|
|
@ -123,6 +136,7 @@ class ImageUrlService
|
|||
imageType = ImageType.BACKDROP,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = backdropTags.first(),
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
|
|
@ -130,6 +144,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = imageTags[imageType],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +160,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
|
||||
)
|
||||
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||
getItemImageUrl(
|
||||
|
|
@ -152,6 +168,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
|
|
@ -159,6 +176,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = imageTags[imageType],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +187,7 @@ class ImageUrlService
|
|||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
tag = imageTags[imageType],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -193,6 +212,11 @@ class ImageUrlService
|
|||
backdropTags = item.data.backdropImageTags.orEmpty(),
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
seriesPrimaryTag = item.data.seriesPrimaryImageTag,
|
||||
parentLogoId = item.data.parentLogoItemId,
|
||||
parentLogoTag = item.data.parentLogoImageTag,
|
||||
parentThumbTag = item.data.parentThumbImageTag,
|
||||
parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
|
|
@ -114,6 +116,7 @@ class IntentService
|
|||
return IntentResult.Target(destinations, true)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal suspend fun prepare(intent: Intent): IntentResult? {
|
||||
val appPrefs = userPreferencesService.flow.first().appPreferences
|
||||
|
||||
|
|
@ -121,39 +124,33 @@ class IntentService
|
|||
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
|
||||
return if (userId != null && serverId != null) {
|
||||
Timber.v("Intent switches user")
|
||||
val user = serverRepository.serverDao.getUser(serverId, userId)
|
||||
if (user != null && !user.isProtected) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
?: IntentResult.Error("Error restoring user")
|
||||
val user = serverRepository.tryChangeUser(serverId, userId)
|
||||
if (user != null) {
|
||||
null
|
||||
} else {
|
||||
IntentResult.Error("Cannot switch to specified user")
|
||||
IntentResult.Error("Error restoring user")
|
||||
}
|
||||
} else {
|
||||
val profileProtected =
|
||||
serverRepository.current.value
|
||||
?.user
|
||||
?.isProtected == true
|
||||
if (appPrefs.signInAutomatically && !profileProtected) {
|
||||
} else if (appPrefs.signInAutomatically) {
|
||||
Timber.v("No current user, so restoring last")
|
||||
val userId = appPrefs.currentUserId?.toUUIDOrNull()
|
||||
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
|
||||
val result = serverRepository.restoreLastSession()
|
||||
Timber.v("Restored result=%s", result)
|
||||
when (result) {
|
||||
RestoredSession.None,
|
||||
is RestoredSession.ServerOnly,
|
||||
-> {
|
||||
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||
}
|
||||
|
||||
if (userId != null && serverId != null) {
|
||||
val current =
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
?: return IntentResult.Error("Error restoring user")
|
||||
if (current.user.isProtected) {
|
||||
is RestoredSession.Success -> {
|
||||
if (result.currentUser.user.isProtected) {
|
||||
IntentResult.Error("Could not auto sign-in, user is protected")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IntentResult.Error("Auto sign-in not enabled or user is protected")
|
||||
}
|
||||
IntentResult.Error("Auto sign-in not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
|
@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
|||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -52,12 +54,8 @@ class LatestNextUpWorker
|
|||
try {
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
|
|||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
||||
|
||||
init {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,11 @@ class MusicService
|
|||
mutex.withLock {
|
||||
if (mediaSession == null) {
|
||||
Timber.i("Starting music MediaSession")
|
||||
mediaSession = MediaSession.Builder(context, player).build()
|
||||
mediaSession =
|
||||
MediaSession
|
||||
.Builder(context, player)
|
||||
.setCallback(TrustAllControllersCallback)
|
||||
.build()
|
||||
activityTracker =
|
||||
TrackActivityPlaybackListener(api, player) {
|
||||
state.value.currentItemId?.let { itemId ->
|
||||
|
|
@ -327,6 +331,7 @@ class MusicService
|
|||
imageUrlService.getItemImageUrl(
|
||||
itemId = albumId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = audio.data.albumPrimaryImageTag,
|
||||
)
|
||||
}
|
||||
return MediaItem
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
|||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -32,9 +33,15 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
|||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.get
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.util.BitstreamFilter
|
||||
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
|
||||
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
|
||||
import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
|
||||
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
|
||||
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
|
|
@ -57,6 +64,7 @@ class PlayerFactory
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
) {
|
||||
@Volatile
|
||||
var currentPlayer: Player? = null
|
||||
|
|
@ -94,10 +102,16 @@ class PlayerFactory
|
|||
val preferDolbyVision =
|
||||
appPreferences.experimentalPreferences
|
||||
.get { preferDolbyVisionOverHdr10Plus } ?: false
|
||||
val convertDolbyVisionProfile7 =
|
||||
DolbyVisionProfile7Conversion.isWanted(
|
||||
appPreferences.experimentalPreferences,
|
||||
deviceProfileService.mediaCodecCapabilitiesTest,
|
||||
)
|
||||
Timber.v(
|
||||
"extensions=%s, assPlaybackMode=%s",
|
||||
"extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
|
||||
extensions,
|
||||
prefs.overrides.assPlaybackMode,
|
||||
convertDolbyVisionProfile7,
|
||||
)
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
|
|
@ -108,9 +122,18 @@ class PlayerFactory
|
|||
}
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val extractorsFactory = createExtractorsFactory()
|
||||
|
||||
// Applied outermost so that a profile 7 track is advertised as 8.1 whichever
|
||||
// extractor, media3's own or libass's Matroska replacement, produced it
|
||||
fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
|
||||
if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
|
||||
var renderersFactory: RenderersFactory =
|
||||
WholphinRenderersFactory(context, decodeAv1, preferDolbyVision)
|
||||
.setEnableDecoderFallback(true)
|
||||
WholphinRenderersFactory(
|
||||
context,
|
||||
decodeAv1,
|
||||
preferDolbyVision,
|
||||
convertDolbyVisionProfile7,
|
||||
).setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode)
|
||||
|
||||
val mediaSourceFactory =
|
||||
|
|
@ -126,15 +149,16 @@ class PlayerFactory
|
|||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||
DefaultMediaSourceFactory(
|
||||
dataSourceFactory,
|
||||
extractorsFactory.withAssMkvSupport(
|
||||
extractorsFactory
|
||||
.withAssMkvSupport(
|
||||
assSubtitleParserFactory,
|
||||
assHandler,
|
||||
),
|
||||
).forDolbyVision(),
|
||||
).setSubtitleParserFactory(assSubtitleParserFactory)
|
||||
} else {
|
||||
DefaultMediaSourceFactory(
|
||||
dataSourceFactory,
|
||||
extractorsFactory,
|
||||
extractorsFactory.forDolbyVision(),
|
||||
)
|
||||
}
|
||||
val disableAudioOffload =
|
||||
|
|
@ -160,8 +184,9 @@ class PlayerFactory
|
|||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -246,9 +271,29 @@ class PlayerFactory
|
|||
fun createMediaSession(player: Player) =
|
||||
MediaSession
|
||||
.Builder(context, player)
|
||||
.setCallback(TrustAllControllersCallback)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`)
|
||||
* only get read-only player commands by default, so play/pause from them is silently dropped.
|
||||
* Restore the pre-1.11 behavior of granting all player commands to every controller.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
object TrustAllControllersCallback : MediaSession.Callback {
|
||||
override fun onConnectAsync(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): ListenableFuture<MediaSession.ConnectionResult> =
|
||||
Futures.immediateFuture(
|
||||
MediaSession.ConnectionResult
|
||||
.AcceptedResultBuilder(session, controller)
|
||||
.setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
val Player.isReleased: Boolean
|
||||
get() {
|
||||
return when (this) {
|
||||
|
|
@ -268,6 +313,7 @@ class WholphinRenderersFactory(
|
|||
context: Context,
|
||||
private val av1Enabled: Boolean,
|
||||
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
|
||||
private val convertDolbyVisionProfile7: Boolean = false,
|
||||
) : DefaultRenderersFactory(context) {
|
||||
@OptIn(ExperimentalApi::class)
|
||||
override fun buildVideoRenderers(
|
||||
|
|
@ -280,12 +326,16 @@ class WholphinRenderersFactory(
|
|||
allowedVideoJoiningTimeMs: Long,
|
||||
out: ArrayList<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 =
|
||||
if (preferDolbyVisionOverHdr10Plus) {
|
||||
BitstreamFilteringCodecAdapterFactory(
|
||||
codecAdapterFactory,
|
||||
listOf(Hdr10PlusMaskingFilter()),
|
||||
)
|
||||
if (bitstreamFilters.isNotEmpty()) {
|
||||
BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
|
||||
} else {
|
||||
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.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPluginLogin
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -39,26 +43,25 @@ class SeerrServerRepository
|
|||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _connection =
|
||||
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
||||
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
||||
private val _connection = MutableStateFlow<SeerrState>(SeerrState())
|
||||
val connection: StateFlow<SeerrState> = _connection
|
||||
|
||||
val current: Flow<CurrentSeerr?> =
|
||||
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
|
||||
_connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
|
||||
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?> =
|
||||
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 }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
*/
|
||||
val active: Flow<Boolean> =
|
||||
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_connection.update { SeerrConnectionStatus.NotConfigured }
|
||||
_connection.update { SeerrState() }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +70,7 @@ class SeerrServerRepository
|
|||
user: SeerrUser,
|
||||
exception: Exception,
|
||||
) {
|
||||
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
|
||||
_connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -78,8 +81,9 @@ class SeerrServerRepository
|
|||
) {
|
||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||
_connection.update {
|
||||
SeerrConnectionStatus.Success(
|
||||
CurrentSeerr(server, user, userConfig, publicSettings),
|
||||
it.copy(
|
||||
connection =
|
||||
SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -169,7 +173,7 @@ class SeerrServerRepository
|
|||
|
||||
suspend fun removeServerForCurrentUser(): Boolean {
|
||||
val user =
|
||||
when (val conn = connection.first()) {
|
||||
when (val conn = connection.value.connection) {
|
||||
SeerrConnectionStatus.NotConfigured -> return false
|
||||
is SeerrConnectionStatus.Error -> conn.user
|
||||
is SeerrConnectionStatus.Success -> conn.current.user
|
||||
|
|
@ -178,6 +182,81 @@ class SeerrServerRepository
|
|||
clear()
|
||||
return rows > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the [SeerrPluginSettings], try to connect to the Seerr server
|
||||
*
|
||||
* @return true if successfully updated the SeerrApi and logged in
|
||||
*/
|
||||
suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
|
||||
val currentUser = serverRepository.currentUser ?: return false
|
||||
if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
|
||||
val server =
|
||||
SeerrServer(
|
||||
id = -1,
|
||||
url = settings.serverUrl,
|
||||
)
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinUserRowId = currentUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
|
||||
is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
|
||||
},
|
||||
username =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> null
|
||||
is SeerrPluginLogin.Local -> settings.login.local.username
|
||||
},
|
||||
password = null,
|
||||
credential = null,
|
||||
)
|
||||
try {
|
||||
val userConfig =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> {
|
||||
Timber.v("maybeAddPluginServer, using API key")
|
||||
seerrApi.update(settings.serverUrl, settings.login.apiKey)
|
||||
seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
|
||||
}
|
||||
|
||||
is SeerrPluginLogin.Local -> {
|
||||
Timber.v("maybeAddPluginServer, using local user")
|
||||
seerrApi.update(settings.serverUrl, null)
|
||||
seerrLogin(
|
||||
seerrApi.api,
|
||||
SeerrAuthMethod.LOCAL,
|
||||
settings.login.local.username,
|
||||
settings.login.local.password,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrPluginLogin.None -> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Success
|
||||
_connection.update { it.copy(pluginProvided = true) }
|
||||
set(server, user, userConfig)
|
||||
return true
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
clear()
|
||||
Timber.w(ex, "Error adding plugin provided seerr")
|
||||
_connection.update {
|
||||
it.copy(
|
||||
pluginProvided = true,
|
||||
connection = SeerrConnectionStatus.Error(server, user, ex),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -185,6 +264,11 @@ class SeerrServerRepository
|
|||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
data class SeerrState(
|
||||
val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
|
||||
val pluginProvided: Boolean = false,
|
||||
)
|
||||
|
||||
sealed interface SeerrConnectionStatus {
|
||||
data object NotConfigured : SeerrConnectionStatus
|
||||
|
||||
|
|
@ -205,11 +289,15 @@ data class CurrentSeerr(
|
|||
val config: SeerrUserConfig,
|
||||
val serverConfig: PublicSettings,
|
||||
) {
|
||||
val requestMovieEnabled: Boolean
|
||||
get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
|
||||
val request4kMovieEnabled: Boolean
|
||||
get() =
|
||||
(serverConfig.movie4kEnabled ?: false) &&
|
||||
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
|
||||
|
||||
val requestTvEnabled: Boolean
|
||||
get() = config.hasPermission(SeerrPermission.REQUEST_TV)
|
||||
val request4kTvEnabled: Boolean
|
||||
get() =
|
||||
(serverConfig.series4kEnabled ?: false) &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.RelativeMediaStream
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.data.model.asRelative
|
||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringProvider
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
|
@ -30,50 +42,69 @@ class StreamChoiceService
|
|||
@Inject
|
||||
constructor(
|
||||
private val serverRepository: ServerRepository,
|
||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||
) {
|
||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
||||
|
||||
suspend fun updateAudio(
|
||||
suspend fun saveSeriesTrackChoice(
|
||||
dto: BaseItemDto,
|
||||
audioLang: String,
|
||||
) = update(dto) {
|
||||
it.copy(
|
||||
audioLanguage = audioLang,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateSubtitles(
|
||||
dto: BaseItemDto,
|
||||
subtitleLang: String?,
|
||||
subtitlesDisabled: Boolean,
|
||||
) = update(dto) {
|
||||
it.copy(
|
||||
subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
|
||||
subtitlesDisabled = subtitlesDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun update(
|
||||
dto: BaseItemDto,
|
||||
update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
|
||||
type: MediaStreamType,
|
||||
stream: RelativeMediaStream?,
|
||||
activation: ActivationFlag = ActivationFlag.ACTIVATED,
|
||||
) {
|
||||
val seriesId = dto.seriesId
|
||||
if (seriesId != null) {
|
||||
val userId = serverRepository.currentUser!!.rowId
|
||||
val currentPlc =
|
||||
playbackLanguageChoiceDao.get(userId, seriesId)
|
||||
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
||||
val newPlc = update.invoke(currentPlc)
|
||||
Timber.v("Saving series PLC: %s", newPlc)
|
||||
playbackLanguageChoiceDao.save(newPlc)
|
||||
val type =
|
||||
when (stream?.type ?: type) {
|
||||
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
|
||||
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
|
||||
else -> return
|
||||
}
|
||||
val userId = serverRepository.currentUser!!.rowId
|
||||
val newStc =
|
||||
buildList {
|
||||
dto.parentId
|
||||
?.let { seasonId ->
|
||||
SeriesTrackChoice(
|
||||
userId = userId,
|
||||
itemId = dto.id,
|
||||
parentId = seasonId,
|
||||
type = type,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
stream = stream,
|
||||
activation = activation,
|
||||
)
|
||||
}?.let(::add)
|
||||
dto.seriesId
|
||||
?.let { seriesId ->
|
||||
SeriesTrackChoice(
|
||||
userId = userId,
|
||||
itemId = dto.id,
|
||||
parentId = seriesId,
|
||||
type = type,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
stream = stream,
|
||||
activation = activation,
|
||||
)
|
||||
}?.let(::add)
|
||||
}
|
||||
Timber.d(
|
||||
"Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
|
||||
dto.id,
|
||||
dto.seriesId,
|
||||
dto.parentId,
|
||||
newStc,
|
||||
)
|
||||
seriesTrackChoiceDao.save(newStc)
|
||||
}
|
||||
|
||||
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
|
||||
dto.seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
|
||||
}
|
||||
suspend fun saveDisabledSeriesTrackChoice(
|
||||
dto: BaseItemDto,
|
||||
type: MediaStreamType,
|
||||
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
|
||||
|
||||
suspend fun saveOnlyForcedSeriesTrackChoice(
|
||||
dto: BaseItemDto,
|
||||
type: MediaStreamType,
|
||||
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
|
||||
|
||||
/**
|
||||
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
|
||||
|
|
@ -104,21 +135,15 @@ class StreamChoiceService
|
|||
*/
|
||||
suspend fun chooseAudioStream(
|
||||
source: MediaSourceInfo,
|
||||
seriesId: UUID?,
|
||||
item: BaseItemDto,
|
||||
itemPlayback: ItemPlayback?,
|
||||
plc: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>?,
|
||||
prefs: UserPreferences,
|
||||
): MediaStream? {
|
||||
val plc =
|
||||
plc ?: seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(
|
||||
serverRepository.currentUser!!.rowId,
|
||||
it,
|
||||
)
|
||||
}
|
||||
): StreamChoiceResult? {
|
||||
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||
return source.mediaStreams?.letNotEmpty { streams ->
|
||||
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
||||
chooseAudioStream(candidates, itemPlayback, plc, prefs)
|
||||
chooseAudioStream(candidates, itemPlayback, stc, prefs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,17 +153,34 @@ class StreamChoiceService
|
|||
fun chooseAudioStream(
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>,
|
||||
prefs: UserPreferences,
|
||||
): MediaStream? =
|
||||
): StreamChoiceResult =
|
||||
if (itemPlayback?.audioIndexEnabled == true) {
|
||||
candidates.firstOrNull { it.index == itemPlayback.audioIndex }
|
||||
candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
|
||||
StreamChoiceReason.Item(
|
||||
itemPlayback,
|
||||
)
|
||||
} else if (stc.isNotEmpty()) {
|
||||
val first = stc.first()
|
||||
val result = scoreStreams(candidates, first)
|
||||
if (result == null) {
|
||||
null with StreamChoiceReason.from(first)
|
||||
} else if (result.isEmpty() && stc.size > 1) {
|
||||
// SeriesTrackChoice did not apply to any streams, but there are more options
|
||||
chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
|
||||
} else if (result.isEmpty()) {
|
||||
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
|
||||
Timber.v("No audio STC applied")
|
||||
chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
|
||||
} else {
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
// If the user has chosen a different language for the series, prefer that
|
||||
val audioLanguage =
|
||||
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
||||
// Otherwise, use the best scored stream
|
||||
Timber.v("Using audio STC from %s", first.parentId)
|
||||
result.first().stream with StreamChoiceReason.from(first, result)
|
||||
}
|
||||
} else {
|
||||
val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
||||
val stream =
|
||||
if (audioLanguage.isNotNullOrBlank()) {
|
||||
val sorted =
|
||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
||||
|
|
@ -150,37 +192,31 @@ class StreamChoiceService
|
|||
candidates.firstOrNull { it.isDefault }
|
||||
?: candidates.firstOrNull()
|
||||
}
|
||||
stream with StreamChoiceReason.UserPreferences
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subtitle stream that should play
|
||||
*/
|
||||
suspend fun chooseSubtitleStream(
|
||||
item: BaseItemDto,
|
||||
source: MediaSourceInfo,
|
||||
audioStream: MediaStream?,
|
||||
seriesId: UUID?,
|
||||
itemPlayback: ItemPlayback?,
|
||||
plc: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>?,
|
||||
prefs: UserPreferences,
|
||||
): MediaStream? {
|
||||
val plc =
|
||||
plc ?: seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(
|
||||
serverRepository.currentUser!!.rowId,
|
||||
it,
|
||||
)
|
||||
}
|
||||
return source.mediaStreams?.letNotEmpty { streams ->
|
||||
): StreamChoiceResult? =
|
||||
source.mediaStreams?.letNotEmpty { streams ->
|
||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
|
||||
chooseSubtitleStream(
|
||||
audioStream?.language,
|
||||
candidates,
|
||||
itemPlayback,
|
||||
plc,
|
||||
stc,
|
||||
prefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves ONLY_FORCED to an actual subtitle track index.
|
||||
|
|
@ -189,7 +225,7 @@ class StreamChoiceService
|
|||
suspend fun resolveSubtitleIndex(
|
||||
source: MediaSourceInfo,
|
||||
audioStreamIndex: Int?,
|
||||
seriesId: UUID?,
|
||||
item: BaseItemDto,
|
||||
subtitleIndex: Int,
|
||||
prefs: UserPreferences,
|
||||
): Int? =
|
||||
|
|
@ -203,17 +239,17 @@ class StreamChoiceService
|
|||
val itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = serverRepository.currentUser!!.rowId,
|
||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
||||
itemId = item.id,
|
||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||
)
|
||||
chooseSubtitleStream(
|
||||
item = item,
|
||||
source = source,
|
||||
audioStream = audioStream,
|
||||
seriesId = seriesId,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = null,
|
||||
stc = null,
|
||||
prefs = prefs,
|
||||
)?.index
|
||||
)?.stream?.index
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -223,37 +259,73 @@ class StreamChoiceService
|
|||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
itemPlayback: ItemPlayback?,
|
||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||
stc: List<SeriesTrackChoice>,
|
||||
prefs: UserPreferences,
|
||||
): MediaStream? {
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
return null
|
||||
}
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
): StreamChoiceResult {
|
||||
if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
|
||||
if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
|
||||
return null with StreamChoiceReason.Item(itemPlayback)
|
||||
} else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
val subtitleLanguage =
|
||||
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||
// Client-side manual override: User selected "Only Forced" in player menu
|
||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
return findForcedTrack(
|
||||
candidates,
|
||||
subtitleLanguage,
|
||||
audioStreamLang,
|
||||
) with StreamChoiceReason.Item(itemPlayback)
|
||||
} else {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
|
||||
StreamChoiceReason.Item(itemPlayback)
|
||||
}
|
||||
} else if (stc.isNotEmpty()) {
|
||||
val first = stc.first()
|
||||
return when (first.activation) {
|
||||
ActivationFlag.DISABLED -> {
|
||||
null with StreamChoiceReason.from(first)
|
||||
}
|
||||
|
||||
ActivationFlag.ONLY_FORCED -> {
|
||||
val subtitleLanguage =
|
||||
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||
val stream =
|
||||
findForcedTrack(
|
||||
candidates,
|
||||
subtitleLanguage,
|
||||
audioStreamLang,
|
||||
)
|
||||
if (stream != null) {
|
||||
stream with StreamChoiceReason.from(first)
|
||||
} else {
|
||||
handleSubtitleStreamChoiceResult(
|
||||
result = emptyList(),
|
||||
audioStreamLang = audioStreamLang,
|
||||
candidates = candidates,
|
||||
itemPlayback = itemPlayback,
|
||||
stc = stc,
|
||||
prefs = prefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ActivationFlag.ACTIVATED -> {
|
||||
val result =
|
||||
scoreStreams(candidates, first)
|
||||
?: throw IllegalStateException("scoreStreams should not return null for activated")
|
||||
handleSubtitleStreamChoiceResult(
|
||||
result = result,
|
||||
audioStreamLang = audioStreamLang,
|
||||
candidates = candidates,
|
||||
itemPlayback = itemPlayback,
|
||||
stc = stc,
|
||||
prefs = prefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val subtitleLanguage =
|
||||
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||
val subtitleMode =
|
||||
when {
|
||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
||||
// User has chosen a series level subtitle language, so override their normal
|
||||
// subtitle mode to display that language
|
||||
SubtitlePlaybackMode.ALWAYS
|
||||
}
|
||||
|
||||
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
|
||||
// Series level settings disables subtitles
|
||||
SubtitlePlaybackMode.NONE
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Fallback to the user's preference
|
||||
when (prefs.userPreferences?.subtitleMode) {
|
||||
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
|
||||
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
|
||||
|
|
@ -262,8 +334,6 @@ class StreamChoiceService
|
|||
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
|
||||
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
|
||||
null -> SubtitlePlaybackMode.DEFAULT
|
||||
}
|
||||
}
|
||||
} ?: SubtitlePlaybackMode.DEFAULT
|
||||
val candidates =
|
||||
candidates
|
||||
|
|
@ -277,7 +347,8 @@ class StreamChoiceService
|
|||
}.thenByDescending { it.isForced && it.language.isUnknown }
|
||||
.thenByDescending { it.isForced },
|
||||
)
|
||||
return when (subtitleMode) {
|
||||
val stream =
|
||||
when (subtitleMode) {
|
||||
SubtitlePlaybackMode.ALWAYS -> {
|
||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||
candidates.firstOrNull {
|
||||
|
|
@ -334,16 +405,46 @@ class StreamChoiceService
|
|||
null
|
||||
}
|
||||
}
|
||||
return stream with StreamChoiceReason.UserPreferences
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the track is forced (via metadata flag or title patterns). */
|
||||
private fun isForcedOrSigns(track: MediaStream): Boolean {
|
||||
if (track.isForced) return true
|
||||
val title = track.title ?: track.displayTitle ?: return false
|
||||
return title.contains("forced", ignoreCase = true) ||
|
||||
title.contains("signs", ignoreCase = true) ||
|
||||
title.contains("songs", ignoreCase = true)
|
||||
/**
|
||||
* Recursively calls [chooseSubtitleStream] based on [result] and [stc]
|
||||
*/
|
||||
private fun handleSubtitleStreamChoiceResult(
|
||||
result: List<ScoredMediaStream>,
|
||||
audioStreamLang: String?,
|
||||
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. */
|
||||
|
|
@ -367,19 +468,92 @@ class StreamChoiceService
|
|||
// 3. Unknown language forced track
|
||||
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
|
||||
}
|
||||
|
||||
suspend fun getSeriesTrackChoices(
|
||||
seriesId: UUID?,
|
||||
seasonId: UUID?,
|
||||
type: SeriesTrackChoiceType,
|
||||
): List<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
|
||||
get() =
|
||||
this == null ||
|
||||
this.isNullOrBlank() ||
|
||||
this.equals("unknown", true) ||
|
||||
this.equals("und", true) ||
|
||||
this.equals("undetermined", true) ||
|
||||
this.equals("mul", true) ||
|
||||
this.equals("zxx", true)
|
||||
|
||||
private val String?.isNotUnknown: Boolean
|
||||
get() = !this.isUnknown
|
||||
|
||||
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
|
||||
|
||||
private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true)
|
||||
|
||||
/** Returns true if the track is forced (via metadata flag or title patterns). */
|
||||
fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track)
|
||||
|
||||
fun isSigns(track: MediaStream): Boolean {
|
||||
val title = track.title ?: track.displayTitle ?: return false
|
||||
return title.contains("forced", ignoreCase = true) ||
|
||||
title.contains("signs", ignoreCase = true) ||
|
||||
title.contains("songs", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the user's preferences, get their preferred language for audio or subtitles
|
||||
*
|
||||
|
|
@ -412,3 +586,153 @@ fun getPreferredLanguage(
|
|||
else -> pref
|
||||
}?.takeIf { it.isNotNullOrBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scores streams based the user's explicit [SeriesTrackChoice]
|
||||
*
|
||||
* @return sorted list of preferred streams or null if stream should be disabled
|
||||
*/
|
||||
fun scoreStreams(
|
||||
streams: List<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.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -63,12 +64,8 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
|
|||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
|
||||
|
|
@ -36,6 +37,7 @@ class UserSwitchListener
|
|||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
|
|
@ -65,13 +67,41 @@ class UserSwitchListener
|
|||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
|
||||
// Check for home settings
|
||||
// Check if plugin is installed, then for home settings & seerr settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
val serverPluginInstalled =
|
||||
try {
|
||||
serverPluginApi.checkInstalled()
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error checking for server plugin")
|
||||
false
|
||||
}
|
||||
Timber.i("Server plugin installed: %s", serverPluginInstalled)
|
||||
serverRepository.serverPluginInstalled.value = serverPluginInstalled
|
||||
|
||||
launchIO {
|
||||
// Check for home settings
|
||||
homeSettingsService.loadCurrentSettings(user)
|
||||
}
|
||||
|
||||
if (BuildConfig.DISCOVER_ENABLED) {
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
if (serverPluginInstalled) {
|
||||
serverPluginApi.fetchSeerrSettings()?.let {
|
||||
Timber.d("Trying seerr auto login")
|
||||
val result = seerrServerRepository.usePluginProvidedServer(it)
|
||||
if (result) {
|
||||
// Successfully used server plugin provided seerr
|
||||
Timber.i("Seerr auto login succeeded")
|
||||
return@launchIO
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.v("Checking for existing configured seerr server")
|
||||
// Either no server plugin seerr or there was an issue with it
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.lastOrNull()
|
||||
|
|
@ -112,3 +142,4 @@ class UserSwitchListener
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
|||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||
|
|
@ -188,6 +190,12 @@ object AppModule {
|
|||
fun seerrApi(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun mostRecentServerProvider(
|
||||
@ApplicationContext context: Context,
|
||||
): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
|||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
|
|
@ -79,6 +80,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
|
|||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
|
|
@ -70,12 +71,8 @@ class TvProviderWorker
|
|||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
// Not active
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
val result = serverRepository.restoreLastSession()
|
||||
if (result !is RestoredSession.Success) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import androidx.compose.ui.text.AnnotatedString
|
|||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT
|
||||
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
|
|
@ -205,6 +208,13 @@ fun formatBytes(
|
|||
|
||||
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
|
||||
|
||||
fun formatMaxBitrate(bitrate: Long) =
|
||||
if (bitrate < MEGA_BIT) {
|
||||
"${bitrate / 1024} kbps"
|
||||
} else {
|
||||
"${bitrate / MEGA_BIT} Mbps"
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val MediaSegmentType.stringRes: Int
|
||||
get() =
|
||||
|
|
@ -300,3 +310,55 @@ fun Resources.formatDuration(duration: Duration): String =
|
|||
else -> getString(R.string.duration_seconds, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun Resources.serverSupportedMessage(
|
||||
serverVersionSupported: ServerVersionSupported,
|
||||
serverVersion: ServerVersion?,
|
||||
): String? {
|
||||
val errorMsg =
|
||||
when (serverVersionSupported) {
|
||||
ServerVersionSupported.SUPPORTED -> {
|
||||
null
|
||||
}
|
||||
|
||||
ServerVersionSupported.NOT_SUPPORTED,
|
||||
ServerVersionSupported.UNKNOWN,
|
||||
-> {
|
||||
getString(R.string.server_version_not_supported)
|
||||
}
|
||||
|
||||
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
|
||||
getString(R.string.server_version_not_supported_future)
|
||||
}
|
||||
}
|
||||
return errorMsg?.let {
|
||||
val msg =
|
||||
if (serverVersion != null) {
|
||||
"$it: $serverVersion"
|
||||
} else {
|
||||
it + ": " + getString(R.string.unknown)
|
||||
}
|
||||
msg + "\n" +
|
||||
when (serverVersionSupported) {
|
||||
ServerVersionSupported.SUPPORTED -> {
|
||||
""
|
||||
}
|
||||
|
||||
ServerVersionSupported.NOT_SUPPORTED,
|
||||
ServerVersionSupported.UNKNOWN,
|
||||
-> {
|
||||
getString(
|
||||
R.string.server_version_required,
|
||||
WholphinApplication.minimumServerVersion.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
|
||||
getString(
|
||||
R.string.server_version_required,
|
||||
WholphinApplication.newMinimumServerVersion.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid
|
|||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -53,19 +52,18 @@ import kotlin.time.Duration
|
|||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CollectionFolderGrid(
|
||||
fun CollectionFolderViewScope.CollectionFolderGrid(
|
||||
preferences: UserPreferences,
|
||||
collectionType: CollectionType?,
|
||||
focusedItem: BaseItem?,
|
||||
items: List<BaseItem?>,
|
||||
sortAndDirection: SortAndDirection,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
viewOptions: ViewOptions,
|
||||
onClickPlay: (Int, BaseItem) -> Unit,
|
||||
initialPosition: Int,
|
||||
gridFocusRequester: FocusRequester,
|
||||
cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -99,18 +97,8 @@ fun CollectionFolderGrid(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
initialPosition = initialPosition,
|
||||
positionCallback = positionCallback,
|
||||
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
imageContentScale = viewOptions.contentScale.scale,
|
||||
imageAspectRatio = viewOptions.aspectRatio.ratio,
|
||||
imageType = viewOptions.imageType,
|
||||
showTitle = viewOptions.showTitles,
|
||||
fillWidth = widthPx,
|
||||
modifier = mod,
|
||||
)
|
||||
cardContent = { details ->
|
||||
cardContent.invoke(this@CollectionFolderGrid, details)
|
||||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
|
|
@ -210,10 +198,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
|
||||
}
|
||||
|
||||
CollectionType.HOMEVIDEOS -> {
|
||||
listOf(BaseItemKind.VIDEO)
|
||||
listOf(BaseItemKind.PHOTO)
|
||||
listOf(BaseItemKind.PHOTO_ALBUM)
|
||||
CollectionType.PHOTOS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
-> {
|
||||
listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
||||
}
|
||||
|
||||
CollectionType.MUSIC -> {
|
||||
|
|
@ -236,10 +224,6 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
listOf(BaseItemKind.PLAYLIST)
|
||||
}
|
||||
|
||||
CollectionType.PHOTOS -> {
|
||||
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
||||
}
|
||||
|
||||
CollectionType.LIVETV -> {
|
||||
listOf(
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
|
|||
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
|
||||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.formatDuration
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
|
|
@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
|||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderList(
|
||||
fun CollectionFolderViewScope.CollectionFolderList(
|
||||
preferences: UserPreferences,
|
||||
collectionType: CollectionType?,
|
||||
focusedItem: BaseItem?,
|
||||
|
|
@ -84,10 +86,10 @@ fun CollectionFolderList(
|
|||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
viewOptions: ViewOptions,
|
||||
onClickPlay: (Int, BaseItem) -> Unit,
|
||||
initialPosition: Int,
|
||||
gridFocusRequester: FocusRequester,
|
||||
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -153,10 +155,8 @@ fun CollectionFolderList(
|
|||
).padding(8.dp),
|
||||
)
|
||||
}
|
||||
CollectionFolderContent(
|
||||
this@CollectionFolderList.CollectionFolderListContent(
|
||||
items = items,
|
||||
viewOptions = viewOptions,
|
||||
collectionType = collectionType,
|
||||
listState = listState,
|
||||
position = position,
|
||||
positionFocusRequester = positionFocusRequester,
|
||||
|
|
@ -167,6 +167,7 @@ fun CollectionFolderList(
|
|||
},
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
itemContent = itemContent,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
|
|
@ -266,45 +267,44 @@ fun CollectionFolderListDetails(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderContent(
|
||||
fun CollectionFolderViewScope.CollectionFolderListContent(
|
||||
items: List<BaseItem?>,
|
||||
viewOptions: ViewOptions,
|
||||
collectionType: CollectionType?,
|
||||
listState: LazyListState,
|
||||
position: Int,
|
||||
positionFocusRequester: FocusRequester,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
onPositionChange: (Int) -> Unit,
|
||||
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val currentPosition by rememberUpdatedState(position)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
|
||||
modifier = modifier.focusRestorer(positionFocusRequester),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val onClick: () -> Unit =
|
||||
remember(index, item) {
|
||||
{ item?.let { onClickItem.invoke(index, item) } }
|
||||
}
|
||||
val onLongClick: () -> Unit =
|
||||
remember(index, item) {
|
||||
{ item?.let { onLongClickItem.invoke(index, item) } }
|
||||
}
|
||||
CollectionFolderListItem(
|
||||
val details =
|
||||
remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
|
||||
GridItemDetails(
|
||||
item = item,
|
||||
dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
|
||||
collectionType = collectionType,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier =
|
||||
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 == position, Modifier.focusRequester(positionFocusRequester)),
|
||||
}.ifElse(
|
||||
index == currentPosition,
|
||||
Modifier.focusRequester(positionFocusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
itemContent.invoke(this@CollectionFolderListContent, details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -522,9 +522,12 @@ fun ListItemAlbum(
|
|||
}
|
||||
val supportingContent =
|
||||
remember(item, dense) {
|
||||
if (!dense && item?.subtitle != null) {
|
||||
// Album artist is already shown as the overline
|
||||
val subtitle =
|
||||
if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle
|
||||
if (!dense && subtitle != null) {
|
||||
@Composable {
|
||||
Text(item.subtitle ?: "")
|
||||
Text(subtitle)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -54,20 +54,25 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.goToRandom
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.BlockingList
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
|
|
@ -89,12 +94,14 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
|
|
@ -146,6 +153,9 @@ class CollectionFolderViewModel
|
|||
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
|
||||
val state: StateFlow<CollectionFolderState> = _state
|
||||
|
||||
private val artistSource: ArtistSource
|
||||
get() = state.value.viewOptions.artistSource
|
||||
|
||||
var position: Int
|
||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||
set(value) {
|
||||
|
|
@ -168,15 +178,25 @@ class CollectionFolderViewModel
|
|||
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
|
||||
libraryDisplayInfoDao.getItem(user, id)
|
||||
}
|
||||
val ancestorDisplayInfo =
|
||||
if (libraryDisplayInfo?.viewOptions == null && item != null) {
|
||||
getAncestorDisplayInfo(item)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
||||
viewOptions =
|
||||
libraryDisplayInfo?.viewOptions
|
||||
?: ancestorDisplayInfo?.viewOptions
|
||||
?: defaultViewOptions,
|
||||
)
|
||||
}
|
||||
|
||||
val sortAndDirection =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
libraryDisplayInfo?.sortAndDirection
|
||||
?: ancestorDisplayInfo?.sortAndDirection
|
||||
} else {
|
||||
null
|
||||
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
||||
|
|
@ -232,6 +252,25 @@ class CollectionFolderViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a sub-folder, returns the saved display info of the closest ancestor that has one
|
||||
*/
|
||||
private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? {
|
||||
if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) {
|
||||
return null
|
||||
}
|
||||
val user = serverRepository.currentUser ?: return null
|
||||
return try {
|
||||
api.libraryApi
|
||||
.getAncestors(item.id, user.id)
|
||||
.content
|
||||
.firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error getting ancestors for %s", item.id)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
newFilter: GetItemsFilter = state.value.filter,
|
||||
newSort: SortAndDirection = state.value.sortAndDirection,
|
||||
|
|
@ -257,7 +296,17 @@ class CollectionFolderViewModel
|
|||
|
||||
override fun saveViewOptions(viewOptions: ViewOptions) {
|
||||
position = 0
|
||||
val artistSourceChanged = viewOptions.artistSource != artistSource
|
||||
_state.update { it.copy(viewOptions = viewOptions) }
|
||||
if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) {
|
||||
loadResults(
|
||||
true,
|
||||
state.value.sortAndDirection,
|
||||
recursive,
|
||||
state.value.filter,
|
||||
useSeriesForPrimary,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||
if (!viewOptions.showBackdrop) {
|
||||
|
|
@ -269,11 +318,19 @@ class CollectionFolderViewModel
|
|||
override fun onClickRandom() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val random =
|
||||
(state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
|
||||
Timber.d("Got random item: %s", random?.id)
|
||||
random?.destination()?.let {
|
||||
navigateTo(random.destination())
|
||||
val state = state.value
|
||||
val list = state.items.successValue as? BlockingList<BaseItem?>
|
||||
if (list != null) {
|
||||
goToRandom(
|
||||
navigateTo = ::navigateTo,
|
||||
parentId = itemId,
|
||||
filter = state.filter,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
recursive = recursive,
|
||||
list = list,
|
||||
)
|
||||
} else {
|
||||
Timber.w("state.value.items is not successful: %s", state.items)
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
|
|
@ -399,6 +456,8 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
GetItemsFilterOverride.ARTIST -> {
|
||||
when (artistSource) {
|
||||
ArtistSource.ARTIST -> {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
createGetArtistsRequest(filter),
|
||||
|
|
@ -407,6 +466,18 @@ class CollectionFolderViewModel
|
|||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
ArtistSource.ALBUM_ARTIST -> {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
createGetAlbumArtistsRequest(filter),
|
||||
GetAlbumArtistsHandler,
|
||||
viewModelScope,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -424,6 +495,17 @@ class CollectionFolderViewModel
|
|||
)
|
||||
}
|
||||
|
||||
private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest {
|
||||
val item = state.value.item.successValue
|
||||
return filter.applyTo(
|
||||
GetAlbumArtistsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
fields = SlimItemFields,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createGetItemsRequest(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
|
|
@ -495,6 +577,8 @@ class CollectionFolderViewModel
|
|||
val filter = state.value.filter
|
||||
when (filter.override) {
|
||||
GetItemsFilterOverride.ARTIST -> {
|
||||
when (artistSource) {
|
||||
ArtistSource.ARTIST -> {
|
||||
GetArtistsHandler.countMatching(
|
||||
api = api,
|
||||
request =
|
||||
|
|
@ -509,6 +593,23 @@ class CollectionFolderViewModel
|
|||
)
|
||||
}
|
||||
|
||||
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
|
||||
// counted up to a letter at all
|
||||
GetItemsFilterOverride.PERSON -> {
|
||||
|
|
@ -813,6 +914,33 @@ fun CollectionFolderViewContent(
|
|||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
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)
|
||||
|
||||
|
|
@ -975,11 +1103,22 @@ fun CollectionFolderViewContent(
|
|||
focusRequester?.tryRequestFocus()
|
||||
clickedRandom = false
|
||||
}
|
||||
val scope =
|
||||
object : CollectionFolderViewScope {
|
||||
override val viewOptions: ViewOptions
|
||||
get() = state.viewOptions
|
||||
override val collectionType: CollectionType?
|
||||
get() = item?.data?.collectionType
|
||||
override val sortAndDirection: SortAndDirection
|
||||
get() = state.sortAndDirection
|
||||
override val collectionSize: Int
|
||||
get() = pager.data.size
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (state.viewOptions.type == ViewOptionsType.GRID) {
|
||||
CollectionFolderGrid(
|
||||
scope.CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
collectionType = item?.data?.collectionType,
|
||||
initialPosition = savedPosition,
|
||||
items = pager.data,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
|
|
@ -993,12 +1132,12 @@ fun CollectionFolderViewContent(
|
|||
positionCallback?.invoke(columns, pos)
|
||||
},
|
||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = state.viewOptions,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
focusedItem = focusedItem,
|
||||
cardContent = gridCardContent,
|
||||
)
|
||||
} else {
|
||||
CollectionFolderList(
|
||||
scope.CollectionFolderList(
|
||||
preferences = preferences,
|
||||
collectionType = item?.data?.collectionType,
|
||||
initialPosition = savedPosition,
|
||||
|
|
@ -1014,9 +1153,9 @@ fun CollectionFolderViewContent(
|
|||
positionCallback?.invoke(columns, pos)
|
||||
},
|
||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = state.viewOptions,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
focusedItem = focusedItem,
|
||||
itemContent = listItemContent,
|
||||
)
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
|
|
@ -1047,6 +1186,7 @@ fun CollectionFolderViewContent(
|
|||
ViewOptionsDialog(
|
||||
viewOptions = state.viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
viewActions.saveViewOptions(state.viewOptions)
|
||||
|
|
@ -1055,3 +1195,13 @@ fun CollectionFolderViewContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
|
||||
*/
|
||||
interface CollectionFolderViewScope {
|
||||
val viewOptions: ViewOptions
|
||||
val collectionType: CollectionType?
|
||||
val sortAndDirection: SortAndDirection
|
||||
val collectionSize: Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,12 @@ fun ContextMenu(
|
|||
favorite = item.favorite,
|
||||
seriesId = item.data.seriesId,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
|
||||
canClearChosenStreams =
|
||||
chosenStreams.let {
|
||||
it?.itemPlayback != null ||
|
||||
it?.audioStc?.isNotEmpty() == true ||
|
||||
it?.subtitleStc?.isNotEmpty() == true
|
||||
},
|
||||
contextMenu = contextMenu,
|
||||
actions = actions,
|
||||
onChooseVersion = {
|
||||
|
|
@ -235,11 +240,11 @@ fun ContextMenu(
|
|||
resources = resources,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
streamChoice =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
chosenStreams?.audioStream
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
chosenStreams?.subtitleStream
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
actions.onChooseTracks.invoke(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceResult
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -230,15 +231,17 @@ fun DialogPopup(
|
|||
onDismissRequest = onDismissRequest,
|
||||
properties = properties,
|
||||
) {
|
||||
val elevatedContainerColor =
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||
DialogPopupContent(
|
||||
title = title,
|
||||
dialogItems = dialogItems,
|
||||
waiting = waiting,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissOnClick = dismissOnClick,
|
||||
elevation = elevation,
|
||||
modifier =
|
||||
Modifier.onKeyEvent { event ->
|
||||
Modifier
|
||||
.onKeyEvent { event ->
|
||||
val code = event.nativeKeyEvent.keyCode
|
||||
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
|
||||
code in
|
||||
|
|
@ -251,7 +254,12 @@ fun DialogPopup(
|
|||
waiting = false
|
||||
}
|
||||
false
|
||||
},
|
||||
}.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
|
||||
.graphicsLayer {
|
||||
this.clip = true
|
||||
this.shape = RoundedCornerShape(28.0.dp)
|
||||
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||
.padding(PaddingValues(24.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -265,20 +273,10 @@ fun DialogPopupContent(
|
|||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissOnClick: Boolean = true,
|
||||
elevation: Dp = 8.dp,
|
||||
) {
|
||||
val elevatedContainerColor =
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
|
||||
.graphicsLayer {
|
||||
this.clip = true
|
||||
this.shape = RoundedCornerShape(28.0.dp)
|
||||
}.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||
.padding(PaddingValues(24.dp)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
|
|
@ -452,12 +450,14 @@ fun ConfirmDialog(
|
|||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 1.dp,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||
) = BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = properties,
|
||||
elevation = elevation,
|
||||
content = {
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -472,6 +472,8 @@ fun ConfirmDialogContent(
|
|||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -502,11 +504,11 @@ fun ConfirmDialogContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
stringRes = R.string.cancel,
|
||||
stringRes = cancelStringRes,
|
||||
onClick = onCancel,
|
||||
)
|
||||
TextButton(
|
||||
stringRes = R.string.confirm,
|
||||
stringRes = confirmStringRes,
|
||||
onClick = onConfirm,
|
||||
)
|
||||
}
|
||||
|
|
@ -651,12 +653,13 @@ fun resourceFor(type: MediaStreamType): Int =
|
|||
fun chooseStream(
|
||||
resources: Resources,
|
||||
streams: List<MediaStream>,
|
||||
currentIndex: Int?,
|
||||
streamChoice: StreamChoiceResult?,
|
||||
type: MediaStreamType,
|
||||
preferredSubtitleLanguage: String?,
|
||||
onClick: (Int) -> Unit,
|
||||
): DialogParams {
|
||||
val filteredStreams = streams.filter { it.type == type }
|
||||
val currentIndex = streamChoice?.stream?.index
|
||||
return DialogParams(
|
||||
fromLongClick = false,
|
||||
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
|
||||
|
|
@ -674,6 +677,16 @@ fun chooseStream(
|
|||
},
|
||||
supportingContent = {
|
||||
},
|
||||
trailingContent =
|
||||
if (currentIndex == null && streamChoice != null) {
|
||||
{
|
||||
Text(
|
||||
text = streamChoice.reason.description.getString(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||
),
|
||||
)
|
||||
|
|
@ -719,6 +732,16 @@ fun chooseStream(
|
|||
supportingContent = {
|
||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||
},
|
||||
trailingContent =
|
||||
if (currentIndex == stream.index) {
|
||||
{
|
||||
Text(
|
||||
text = streamChoice.reason.description.getString(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onClick.invoke(stream.index) },
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) =
|
|||
),
|
||||
),
|
||||
RecommendedRow(
|
||||
title = R.string.top_unwatched,
|
||||
title = R.string.top_rated,
|
||||
handler = GetItemsRequestHandler,
|
||||
request =
|
||||
GetItemsRequest(
|
||||
|
|
@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) =
|
|||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
enableUserData = true,
|
||||
isPlayed = false,
|
||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
enableTotalRecordCount = false,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ class StudioViewModel
|
|||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
fillWidth = cardWidthPx,
|
||||
tag = it.imageTags?.get(ImageType.THUMB),
|
||||
)
|
||||
Studio(
|
||||
id = it.id,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? {
|
|||
item.data.seriesId != null &&
|
||||
item.data.parentLogoImageTag != null
|
||||
) {
|
||||
imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = item.data.seriesId!!,
|
||||
imageType = ImageType.LOGO,
|
||||
tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
|
||||
)
|
||||
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ fun VideoStreamDetails(
|
|||
) = VideoStreamDetails(
|
||||
chosenStreams?.source,
|
||||
chosenStreams?.videoStream,
|
||||
chosenStreams?.audioStream,
|
||||
chosenStreams?.subtitleStream,
|
||||
chosenStreams?.audioStream?.stream,
|
||||
chosenStreams?.subtitleStream?.stream,
|
||||
numberOfVersions,
|
||||
modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ fun ViewOptionsDialog(
|
|||
onDismissRequest: () -> Unit,
|
||||
onViewOptionsChange: (ViewOptions) -> Unit,
|
||||
defaultViewOptions: ViewOptions = ViewOptions(),
|
||||
showArtistSource: Boolean = false,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
|
|
@ -72,11 +73,11 @@ fun ViewOptionsDialog(
|
|||
window.setDimAmount(0f)
|
||||
}
|
||||
val options =
|
||||
remember(viewOptions.type) {
|
||||
remember(viewOptions.type, showArtistSource) {
|
||||
when (viewOptions.type) {
|
||||
ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS
|
||||
ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS
|
||||
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
|
||||
ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource)
|
||||
ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource)
|
||||
ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
|
|
@ -130,6 +131,12 @@ fun ViewOptionsDialog(
|
|||
}
|
||||
}
|
||||
|
||||
/** Which Jellyfin artists list backs the Music > Artists tab */
|
||||
enum class ArtistSource {
|
||||
ARTIST,
|
||||
ALBUM_ARTIST,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the customizable view changes from the user
|
||||
*
|
||||
|
|
@ -146,6 +153,7 @@ data class ViewOptions(
|
|||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
val showTitles: Boolean = true,
|
||||
val type: ViewOptionsType = ViewOptionsType.GRID,
|
||||
val extras: ViewOptionsExtras = ViewOptionsExtras.None,
|
||||
) {
|
||||
companion object {
|
||||
val ViewOptionsColumns =
|
||||
|
|
@ -250,12 +258,34 @@ data class ViewOptions(
|
|||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsArtistSource =
|
||||
AppChoicePreference<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 =
|
||||
AppClickablePreference<ViewOptions>(
|
||||
title = R.string.reset,
|
||||
)
|
||||
|
||||
val GRID_OPTIONS =
|
||||
/**
|
||||
* Get the options for a grid [ViewOptions]
|
||||
*/
|
||||
fun buildGridOptions(showArtistSource: Boolean) =
|
||||
buildList {
|
||||
if (showArtistSource) add(ViewOptionsArtistSource)
|
||||
addAll(GRID_OPTIONS)
|
||||
}
|
||||
|
||||
private val GRID_OPTIONS =
|
||||
listOf(
|
||||
ViewOptionsTypePref,
|
||||
ViewOptionsImageType,
|
||||
|
|
@ -269,7 +299,16 @@ data class ViewOptions(
|
|||
ViewOptionsReset,
|
||||
)
|
||||
|
||||
val LIST_OPTIONS =
|
||||
/**
|
||||
* Get the options for a list [ViewOptions]
|
||||
*/
|
||||
fun buildListOptions(showArtistSource: Boolean) =
|
||||
buildList {
|
||||
if (showArtistSource) add(ViewOptionsArtistSource)
|
||||
addAll(LIST_OPTIONS)
|
||||
}
|
||||
|
||||
private val LIST_OPTIONS =
|
||||
listOf(
|
||||
ViewOptionsTypePref,
|
||||
ViewOptionsDetailHeader,
|
||||
|
|
@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions
|
|||
|
||||
else -> ViewOptionsPoster
|
||||
}
|
||||
|
||||
/**
|
||||
* Extras [ViewOptions] info that is context or library specific
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface ViewOptionsExtras {
|
||||
@Serializable
|
||||
data object None : ViewOptionsExtras
|
||||
|
||||
@Serializable
|
||||
data class MusicLibrary(
|
||||
val artistSource: ArtistSource = ArtistSource.ARTIST,
|
||||
) : ViewOptionsExtras
|
||||
}
|
||||
|
||||
/**
|
||||
* The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary]
|
||||
*/
|
||||
val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ import android.media.AudioManager
|
|||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.Display.HdrCapabilities
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalGridApi
|
||||
import androidx.compose.foundation.layout.Grid
|
||||
import androidx.compose.foundation.layout.GridFlow
|
||||
import androidx.compose.foundation.layout.GridTrackSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType
|
|||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -40,9 +46,11 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -56,9 +64,11 @@ import kotlinx.coroutines.withContext
|
|||
import org.acra.util.versionCodeLong
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -68,17 +78,18 @@ class DebugViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
val serverRepository: ServerRepository,
|
||||
val itemPlaybackDao: ItemPlaybackDao,
|
||||
val seriesTrackChoiceDao: SeriesTrackChoiceDao,
|
||||
val clientInfo: ClientInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
) : ViewModel() {
|
||||
val state = MutableStateFlow(DebugState())
|
||||
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(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 display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
display.supportedModes.orEmpty()
|
||||
displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
}
|
||||
|
||||
val av1Included by lazy {
|
||||
|
|
@ -159,7 +170,7 @@ class DebugViewModel
|
|||
state.update {
|
||||
it.copy(
|
||||
deviceInfo = deviceInfoList,
|
||||
displayModes = supportedModes.map { it.toString() },
|
||||
displayModes = display.supportedModes.map { it.toDisplayMode(display) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +180,13 @@ class DebugViewModel
|
|||
itemPlaybacks.value = results
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser?.rowId?.let {
|
||||
val results = seriesTrackChoiceDao.getAll(it)
|
||||
Timber.v("Got %s SeriesTrackChoice", results.size)
|
||||
results.forEach { Timber.v("%s", it) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val logcat = getLogCatLines()
|
||||
this@DebugViewModel.logcat.value = logcat
|
||||
|
|
@ -206,6 +224,20 @@ class DebugViewModel
|
|||
audioManager.registerAudioDeviceCallback(callback, null)
|
||||
addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val hdrSupport =
|
||||
buildMap {
|
||||
put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10())
|
||||
put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus())
|
||||
put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision())
|
||||
put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL())
|
||||
put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8())
|
||||
put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10())
|
||||
put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus())
|
||||
put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision())
|
||||
}
|
||||
state.update { it.copy(hdrSupport = hdrSupport) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -262,8 +294,9 @@ data class LogcatLine(
|
|||
data class DebugState(
|
||||
val appInfo: List<String> = emptyList(),
|
||||
val deviceInfo: List<String> = emptyList(),
|
||||
val displayModes: List<String> = emptyList(),
|
||||
val displayModes: List<DisplayMode> = emptyList(),
|
||||
val audioInfo: List<String> = emptyList(),
|
||||
val hdrSupport: Map<String, Boolean> = emptyMap(),
|
||||
)
|
||||
|
||||
val AudioDeviceInfo.details: String
|
||||
|
|
@ -383,6 +416,7 @@ val AudioDeviceInfo.details: String
|
|||
"productName=$productName, address=$addressStr)"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalGridApi::class)
|
||||
@Composable
|
||||
fun DebugPage(
|
||||
preferences: UserPreferences,
|
||||
|
|
@ -466,15 +500,54 @@ fun DebugPage(
|
|||
BodyText(it)
|
||||
}
|
||||
|
||||
SubSectionTitle("Display Modes")
|
||||
state.displayModes.forEach {
|
||||
BodyText(it)
|
||||
}
|
||||
|
||||
SubSectionTitle("Audio Devices")
|
||||
state.audioInfo.forEach {
|
||||
BodyText(it)
|
||||
}
|
||||
|
||||
SubSectionTitle("Display Modes")
|
||||
Grid(
|
||||
config = {
|
||||
repeat(6) { column(GridTrackSize.Auto) }
|
||||
flow = GridFlow.Row
|
||||
columnGap(8.dp)
|
||||
rowGap(8.dp)
|
||||
},
|
||||
) {
|
||||
EmphasizedBodyText("ID")
|
||||
EmphasizedBodyText("Width")
|
||||
EmphasizedBodyText("Height")
|
||||
EmphasizedBodyText("FPS")
|
||||
EmphasizedBodyText("HDR")
|
||||
EmphasizedBodyText("Alt FPS")
|
||||
|
||||
state.displayModes.forEachIndexed { index, mode ->
|
||||
BodyText(mode.id.toString())
|
||||
BodyText(mode.width.toString())
|
||||
BodyText(mode.height.toString())
|
||||
BodyText(mode.fps)
|
||||
BodyText(mode.hdr)
|
||||
BodyText(mode.altFps)
|
||||
}
|
||||
}
|
||||
|
||||
SubSectionTitle("Detected HDR Support")
|
||||
Grid(
|
||||
config = {
|
||||
column(GridTrackSize.Auto)
|
||||
column(GridTrackSize.Auto)
|
||||
flow = GridFlow.Row
|
||||
columnGap(8.dp)
|
||||
rowGap(4.dp)
|
||||
},
|
||||
) {
|
||||
EmphasizedBodyText("HDR Format")
|
||||
EmphasizedBodyText("Supported")
|
||||
state.hdrSupport.forEach { (name, supported) ->
|
||||
BodyText(name)
|
||||
BodyText(supported.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
|
|
@ -565,11 +638,29 @@ private fun SubSectionTitle(text: String) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun BodyText(text: String) {
|
||||
private fun BodyText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmphasizedBodyText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -601,3 +692,45 @@ private val THIRD_PARTY_TAGS =
|
|||
"HardwareConfig:W",
|
||||
"DfltImageHeaderParser:W",
|
||||
)
|
||||
|
||||
data class DisplayMode(
|
||||
val id: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val fps: String,
|
||||
val hdr: String,
|
||||
val altFps: String,
|
||||
)
|
||||
|
||||
fun Display.Mode.toDisplayMode(display: Display): DisplayMode {
|
||||
val fps = String.format(Locale.getDefault(), "%.3f", refreshRate)
|
||||
val altFps =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
alternativeRefreshRates.joinToString(", ") {
|
||||
String.format(Locale.getDefault(), "%.3f", it)
|
||||
}
|
||||
} else {
|
||||
"N/A"
|
||||
}
|
||||
val hdr =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) }
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) }
|
||||
?: "None found"
|
||||
} else {
|
||||
"N/A"
|
||||
}
|
||||
return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps)
|
||||
}
|
||||
|
||||
fun hdrTypeToString(type: Int): String =
|
||||
when (type) {
|
||||
HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
|
||||
HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+"
|
||||
HdrCapabilities.HDR_TYPE_HLG -> "HLG"
|
||||
HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+"
|
||||
HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi"
|
||||
HdrCapabilities.HDR_TYPE_INVALID -> "Invalid"
|
||||
else -> "Unknown ($type)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.util.goToRandom
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.BlockingList
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
|
|
@ -700,11 +701,25 @@ class FavoritesViewModel
|
|||
viewModelScope.launchIO {
|
||||
try {
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
val random =
|
||||
(collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
|
||||
Timber.d("Got random item: %s", random?.id)
|
||||
random?.destination()?.let {
|
||||
navigateTo(random.destination())
|
||||
val list =
|
||||
collectionState.items.successValue as? BlockingList<BaseItem?>
|
||||
if (list != null) {
|
||||
goToRandom(
|
||||
navigateTo = ::navigateTo,
|
||||
parentId = libraryDisplayItemId(type),
|
||||
filter =
|
||||
collectionState.filter.copy(
|
||||
includeItemTypes = listOf(type),
|
||||
),
|
||||
sortAndDirection = collectionState.sortAndDirection,
|
||||
recursive = true,
|
||||
list = list,
|
||||
)
|
||||
} else {
|
||||
Timber.w(
|
||||
"state.value.items is not successful: %s",
|
||||
collectionState.items,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
|
|
@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -75,7 +74,7 @@ class HomeRowGridViewModel
|
|||
val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
private val navigationManager: NavigationManager,
|
||||
|
|
@ -128,7 +127,7 @@ class HomeRowGridViewModel
|
|||
val libraries =
|
||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val result =
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = rowConfig,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ fun PersonPage(
|
|||
imageUrlService.getItemImageUrl(
|
||||
itemId = person.id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = person.data.imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
}
|
||||
// A long filmography is hard to navigate as a row, so it is cut at the same
|
||||
|
|
|
|||
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.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.data.model.canRequestMovies
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
|
|
@ -84,14 +83,12 @@ fun DiscoverMovieDetails(
|
|||
}
|
||||
val state by viewModel.state.collectAsState()
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val requestEnabled by viewModel.requestEnabled.collectAsState(false)
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showRequestDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
when (val st = state.movie) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
|
|
@ -153,6 +150,7 @@ fun DiscoverMovieDetails(
|
|||
RequestMovieDialog(
|
||||
loading = state.profileLoading,
|
||||
data = state.requestData,
|
||||
requestEnabled = requestEnabled,
|
||||
request4kEnabled = request4kEnabled,
|
||||
movie = movie,
|
||||
onSubmit = {
|
||||
|
|
@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||
canRequest = userConfig.canRequestMovies,
|
||||
canCancel = canCancel,
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ class DiscoverMovieViewModel
|
|||
val state: StateFlow<DiscoverMovieState> = _state
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val requestEnabled =
|
||||
seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
|
||||
val request4kEnabled =
|
||||
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
|
||||
|
||||
|
|
@ -194,7 +196,7 @@ class DiscoverMovieViewModel
|
|||
return@launchIO
|
||||
}
|
||||
try {
|
||||
seerrService.api.requestApi.requestPost(
|
||||
val postRequest =
|
||||
RequestPostRequest(
|
||||
is4k = request.is4k,
|
||||
mediaId = request.movieId,
|
||||
|
|
@ -208,8 +210,9 @@ class DiscoverMovieViewModel
|
|||
profileId = request.profileId,
|
||||
rootFolder = request.folder,
|
||||
tags = emptyList(),
|
||||
),
|
||||
)
|
||||
Timber.v("postRequest=%s", postRequest)
|
||||
seerrService.api.requestApi.requestPost(postRequest)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
|
|
|
|||
|
|
@ -37,9 +37,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
|
|||
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.data.model.canRequestTv
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
|
|
@ -88,6 +87,7 @@ fun DiscoverSeriesDetails(
|
|||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val state by viewModel.state.collectAsState()
|
||||
val requestEnabled by viewModel.requestEnabled.collectAsState()
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
|
|
@ -112,6 +112,7 @@ fun DiscoverSeriesDetails(
|
|||
DiscoverSeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = item,
|
||||
availability = state.availability,
|
||||
userConfig = userConfig,
|
||||
rating = state.rating,
|
||||
canCancel = state.canCancelRequest,
|
||||
|
|
@ -188,6 +189,7 @@ fun DiscoverSeriesDetails(
|
|||
title = state.tvSeries.successValue?.name ?: "",
|
||||
seasons = state.seasons,
|
||||
seasons4k = state.seasons4k,
|
||||
requestEnabled = requestEnabled,
|
||||
request4kEnabled = request4kEnabled,
|
||||
onSubmit = {
|
||||
showRequestSeasonDialog = false
|
||||
|
|
@ -212,6 +214,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
preferences: UserPreferences,
|
||||
userConfig: SeerrUserConfig?,
|
||||
series: TvDetails,
|
||||
availability: SeerrAvailability,
|
||||
rating: SourceRatings?,
|
||||
canCancel: Boolean,
|
||||
seasons: List<RequestSeason>,
|
||||
|
|
@ -275,9 +278,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||
)
|
||||
ExpandableDiscoverButtons(
|
||||
availability =
|
||||
SeerrAvailability.from(series.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
availability = availability,
|
||||
requestOnClick = requestOnClick,
|
||||
pendingOnClick = requestOnClick,
|
||||
cancelOnClick = cancelOnClick,
|
||||
|
|
@ -291,7 +292,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||
canRequest = userConfig.canRequestTv,
|
||||
canCancel = canCancel,
|
||||
trailers = trailers,
|
||||
trailerOnClick = trailerOnClick,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ class DiscoverSeriesViewModel
|
|||
val state: StateFlow<DiscoverSeriesState> = _state
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val requestEnabled =
|
||||
seerrServerRepository.current
|
||||
.map { it?.requestTvEnabled ?: false }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val request4kEnabled =
|
||||
seerrServerRepository.current
|
||||
.map { it?.request4kTvEnabled ?: false }
|
||||
|
|
@ -108,7 +112,7 @@ class DiscoverSeriesViewModel
|
|||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateSeasonStatus(tv)
|
||||
updateCanCancel()
|
||||
updateAvailability()
|
||||
|
||||
viewModelScope.launchIO {
|
||||
val rating =
|
||||
|
|
@ -190,6 +194,7 @@ class DiscoverSeriesViewModel
|
|||
tv: TvDetails,
|
||||
is4k: Boolean,
|
||||
) {
|
||||
Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
|
||||
val currentUserId = seerrServerRepository.currentUserId.first()
|
||||
val seasonStatus = mutableMapOf<Int, RequestStatus>()
|
||||
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
|
||||
|
|
@ -230,7 +235,10 @@ class DiscoverSeriesViewModel
|
|||
seasonStatus[season.seasonNumber] = new
|
||||
}
|
||||
editable[season.seasonNumber] =
|
||||
currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
|
||||
currentUserId == req.requestedBy?.id && (
|
||||
req.status == RequestStatus.PENDING.status ||
|
||||
req.status == RequestStatus.DECLINED.status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -299,7 +307,7 @@ class DiscoverSeriesViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
private suspend fun updateAvailability() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel =
|
||||
canUserCancelRequest(
|
||||
|
|
@ -308,7 +316,32 @@ class DiscoverSeriesViewModel
|
|||
?.mediaInfo
|
||||
?.requests,
|
||||
)
|
||||
_state.update { it.copy(canCancelRequest = canCancel) }
|
||||
val status =
|
||||
state.value.tvSeries.successValue?.let { tv ->
|
||||
val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
|
||||
if (overallStatus == SeerrAvailability.UNKNOWN) {
|
||||
val userRequests =
|
||||
tv.mediaInfo
|
||||
?.requests
|
||||
?.filter { it.requestedBy?.id == user?.id }
|
||||
.orEmpty()
|
||||
if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
|
||||
SeerrAvailability.PENDING
|
||||
} else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
|
||||
SeerrAvailability.PROCESSING
|
||||
} else {
|
||||
SeerrAvailability.UNKNOWN
|
||||
}
|
||||
} else {
|
||||
overallStatus
|
||||
}
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
canCancelRequest = canCancel,
|
||||
availability = status ?: SeerrAvailability.UNKNOWN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestOnClick() {
|
||||
|
|
@ -391,7 +424,7 @@ class DiscoverSeriesViewModel
|
|||
|
||||
fetchAndSetItem().await()?.let {
|
||||
updateSeasonStatus(it)
|
||||
updateCanCancel()
|
||||
updateAvailability()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -412,7 +445,7 @@ class DiscoverSeriesViewModel
|
|||
|
||||
fetchAndSetItem().await()?.let {
|
||||
updateSeasonStatus(it)
|
||||
updateCanCancel()
|
||||
updateAvailability()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -431,4 +464,5 @@ data class DiscoverSeriesState(
|
|||
val canCancelRequest: Boolean = false,
|
||||
val profileLoading: LoadingState = LoadingState.Pending,
|
||||
val requestData: SeerrRequestData = SeerrRequestData(),
|
||||
val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
fun RequestMovieDialog(
|
||||
loading: LoadingState,
|
||||
data: SeerrRequestData,
|
||||
requestEnabled: Boolean,
|
||||
request4kEnabled: Boolean,
|
||||
movie: MovieDetails,
|
||||
onSubmit: (MovieRequest) -> Unit,
|
||||
|
|
@ -54,10 +55,11 @@ fun RequestMovieDialog(
|
|||
RequestMovie(
|
||||
loading = loading,
|
||||
data = data,
|
||||
requestEnabled = requestEnabled,
|
||||
request4kEnabled = request4kEnabled,
|
||||
movie = movie,
|
||||
onSubmit = onSubmit,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
modifier = Modifier.padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +68,7 @@ fun RequestMovieDialog(
|
|||
fun RequestMovie(
|
||||
loading: LoadingState,
|
||||
data: SeerrRequestData,
|
||||
requestEnabled: Boolean,
|
||||
request4kEnabled: Boolean,
|
||||
movie: MovieDetails,
|
||||
onSubmit: (MovieRequest) -> Unit,
|
||||
|
|
@ -90,7 +93,8 @@ fun RequestMovie(
|
|||
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
|
||||
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
|
||||
) {
|
||||
if (request4kEnabled) {
|
||||
// Both enabled, show choice
|
||||
if (requestEnabled && request4kEnabled) {
|
||||
val items =
|
||||
remember {
|
||||
listOf(
|
||||
|
|
@ -118,12 +122,13 @@ fun RequestMovie(
|
|||
)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
|
||||
onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RequestMovieWithOptions(
|
||||
data = data,
|
||||
requestEnabled = requestEnabled,
|
||||
request4kEnabled = request4kEnabled,
|
||||
movie = movie,
|
||||
onSubmit = onSubmit,
|
||||
|
|
@ -137,12 +142,13 @@ fun RequestMovie(
|
|||
@Composable
|
||||
private fun RequestMovieWithOptions(
|
||||
data: SeerrRequestData,
|
||||
requestEnabled: Boolean,
|
||||
request4kEnabled: Boolean,
|
||||
movie: MovieDetails,
|
||||
onSubmit: (MovieRequest) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var is4k by remember { mutableStateOf(request4kEnabled) }
|
||||
var is4k by remember { mutableStateOf(!requestEnabled) }
|
||||
var profile by remember(is4k) {
|
||||
mutableStateOf(
|
||||
if (is4k) {
|
||||
|
|
@ -185,7 +191,7 @@ private fun RequestMovieWithOptions(
|
|||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
if (request4kEnabled) {
|
||||
if (requestEnabled && request4kEnabled) {
|
||||
ClickSwitch(
|
||||
label = stringResource(R.string.request_4k),
|
||||
checked = is4k,
|
||||
|
|
@ -377,7 +383,7 @@ fun MovieRequestPreview() {
|
|||
),
|
||||
profiles4k =
|
||||
listOf(
|
||||
SeerrProfile(1, "HD", true),
|
||||
SeerrProfile(1, "4K", true),
|
||||
SeerrProfile(2, "Ultra HD", false),
|
||||
),
|
||||
rootFolders4k =
|
||||
|
|
@ -385,7 +391,8 @@ fun MovieRequestPreview() {
|
|||
SeerrRootFolder(1, "/movies", "400GB", true),
|
||||
),
|
||||
),
|
||||
request4kEnabled = false,
|
||||
requestEnabled = true,
|
||||
request4kEnabled = true,
|
||||
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
|
||||
onSubmit = {},
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -65,11 +65,12 @@ fun RequestSeasons(
|
|||
seasons: List<RequestSeason>,
|
||||
seasons4k: List<RequestSeason>,
|
||||
data: SeerrRequestData,
|
||||
requestEnabled: Boolean,
|
||||
request4kEnabled: Boolean,
|
||||
onSubmit: (TvRequest) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var is4k by remember { mutableStateOf(request4kEnabled) }
|
||||
var is4k by remember { mutableStateOf(!requestEnabled) }
|
||||
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
|
||||
|
||||
val allSeasonNumbers =
|
||||
|
|
@ -161,7 +162,7 @@ fun RequestSeasons(
|
|||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
if (request4kEnabled) {
|
||||
if (requestEnabled && request4kEnabled) {
|
||||
ClickSwitch(
|
||||
label = stringResource(R.string.request_4k),
|
||||
checked = is4k,
|
||||
|
|
@ -391,6 +392,7 @@ fun RequestSeasonsDialog(
|
|||
data: SeerrRequestData,
|
||||
seasons: List<RequestSeason>,
|
||||
seasons4k: List<RequestSeason>,
|
||||
requestEnabled: Boolean,
|
||||
request4kEnabled: Boolean,
|
||||
onSubmit: (TvRequest) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
|
|
@ -418,6 +420,7 @@ fun RequestSeasonsDialog(
|
|||
data = data,
|
||||
seasons = seasons,
|
||||
seasons4k = seasons4k,
|
||||
requestEnabled = requestEnabled,
|
||||
request4kEnabled = request4kEnabled,
|
||||
onSubmit = onSubmit,
|
||||
modifier =
|
||||
|
|
@ -475,6 +478,7 @@ fun RequestSeasonsPreview() {
|
|||
SeerrRootFolder(1, "/tv", "400GB", true),
|
||||
),
|
||||
),
|
||||
requestEnabled = true,
|
||||
request4kEnabled = true,
|
||||
onSubmit = { },
|
||||
modifier = Modifier.width(400.dp),
|
||||
|
|
|
|||
|
|
@ -145,11 +145,10 @@ class EpisodeViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
|
|
@ -163,7 +162,6 @@ class EpisodeViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -171,10 +169,7 @@ class EpisodeViewModel
|
|||
trackIndex = trackIndex,
|
||||
type = type,
|
||||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,11 @@ class LiveTvViewModel
|
|||
number = it.channelNumber,
|
||||
name = it.channelName ?: it.name,
|
||||
imageUrl =
|
||||
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = it.imageTags?.get(ImageType.PRIMARY),
|
||||
),
|
||||
favorite = it.userData?.isFavorite == true,
|
||||
)
|
||||
}
|
||||
|
|
@ -299,6 +303,7 @@ class LiveTvViewModel
|
|||
imageUrlService.getItemImageUrl(
|
||||
dto.id,
|
||||
ImageType.PRIMARY,
|
||||
tag = dto.imageTags?.get(ImageType.PRIMARY),
|
||||
),
|
||||
)
|
||||
if (index == 0) {
|
||||
|
|
|
|||
|
|
@ -223,11 +223,10 @@ class MovieViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
|
|
@ -241,7 +240,6 @@ class MovieViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -249,10 +247,7 @@ class MovieViewModel
|
|||
trackIndex = trackIndex,
|
||||
type = type,
|
||||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -561,11 +561,10 @@ class SeriesViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
|
|
@ -579,7 +578,6 @@ class SeriesViewModel
|
|||
) {
|
||||
viewModelScope.launchIO {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val result =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = item,
|
||||
|
|
@ -587,10 +585,7 @@ class SeriesViewModel
|
|||
trackIndex = trackIndex,
|
||||
type = type,
|
||||
)
|
||||
val chosen =
|
||||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
|
|
@ -62,6 +63,7 @@ class HomeViewModel
|
|||
val serverReportService: ServerReportService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val backdropService: BackdropService,
|
||||
|
|
@ -186,7 +188,7 @@ class HomeViewModel
|
|||
semaphore.withPermit {
|
||||
Timber.v("Fetching row: %s", row)
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
|
|||
@Serializable
|
||||
data object GlobalSettings : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object SourceSettings : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object Presets : HomeSettingsDestination
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ fun HomeSettingsGlobal(
|
|||
preferences: AppPreferences,
|
||||
onPreferenceChange: (AppPreferences) -> Unit,
|
||||
onClickResize: (Int) -> Unit,
|
||||
onClickSave: () -> Unit,
|
||||
onClickLoad: () -> Unit,
|
||||
onClickLoadWeb: () -> Unit,
|
||||
onClickReset: () -> Unit,
|
||||
onClickViewNextUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -146,62 +142,6 @@ fun HomeSettingsGlobal(
|
|||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.save_to_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_up),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickSave,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_down),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoad,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_web_client),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoadWeb,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.reset),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrows_rotate),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickReset,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -64,11 +65,14 @@ fun HomeSettingsPage(
|
|||
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
||||
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
|
||||
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
|
||||
var showSaveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
val serverPluginActive by viewModel.serverPluginActive.collectAsState()
|
||||
var position by rememberPosition(0, 0)
|
||||
// TODO discover rows
|
||||
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
|
||||
val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
|
||||
|
||||
// Adds a row, waits until its done loading, then scrolls to the new row
|
||||
fun addRow(
|
||||
|
|
@ -86,6 +90,10 @@ fun HomeSettingsPage(
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler(settingsHaveChanged) {
|
||||
showSaveDialog = true
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
|
|
@ -137,6 +145,7 @@ fun HomeSettingsPage(
|
|||
position = RowColumn(index, 0)
|
||||
}
|
||||
},
|
||||
onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -282,6 +291,16 @@ fun HomeSettingsPage(
|
|||
}
|
||||
},
|
||||
onClickResize = { viewModel.resizeCards(it) },
|
||||
onClickViewNextUp = {
|
||||
showRemovedNextUpDialog = true
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
HomeSettingsDestination.SourceSettings -> {
|
||||
HomeSettingsSourcePage(
|
||||
source = state.source,
|
||||
onClickSave = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_server_settings) {
|
||||
|
|
@ -294,6 +313,13 @@ fun HomeSettingsPage(
|
|||
viewModel.loadFromRemote()
|
||||
}
|
||||
},
|
||||
serverPluginActive = serverPluginActive,
|
||||
onClickLoadPlugin = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
viewModel.loadFromRemotePlugin()
|
||||
}
|
||||
},
|
||||
onClickLoadWeb = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
|
|
@ -306,9 +332,6 @@ fun HomeSettingsPage(
|
|||
addRow(false) { viewModel.resetToDefault() }
|
||||
}
|
||||
},
|
||||
onClickViewNextUp = {
|
||||
showRemovedNextUpDialog = true
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -375,6 +398,29 @@ fun HomeSettingsPage(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (showSaveDialog) {
|
||||
SaveHomeSettingsDialog(
|
||||
onDismissRequest = { showSaveDialog = false },
|
||||
onSaveLocal = {
|
||||
scope.launch {
|
||||
viewModel.saveToLocal().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
onSaveServer = {
|
||||
scope.launch {
|
||||
viewModel.saveToRemote().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
onDiscard = {
|
||||
scope.launch {
|
||||
viewModel.discardChanges().join()
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ShowConfirm(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ enum class MoveDirection {
|
|||
DOWN,
|
||||
}
|
||||
|
||||
private const val POS_ADD_ROW = 0
|
||||
private const val POS_SOURCE = POS_ADD_ROW + 1
|
||||
private const val POS_SETTINGS = POS_SOURCE + 1
|
||||
private const val POS_PRESETS = POS_SETTINGS + 1
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsRowList(
|
||||
state: HomePageSettingsState,
|
||||
|
|
@ -58,13 +63,14 @@ fun HomeSettingsRowList(
|
|||
onClickPresets: () -> Unit,
|
||||
onClickMove: (MoveDirection, Int) -> Unit,
|
||||
onClickDelete: (Int) -> Unit,
|
||||
onClickSourceSettings: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val itemsBeforeRows = 4
|
||||
val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
|
||||
val focusRequesters =
|
||||
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
|
||||
|
||||
|
|
@ -80,7 +86,7 @@ fun HomeSettingsRowList(
|
|||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(focusRequesters[0]),
|
||||
.focusRestorer(focusRequesters[POS_ADD_ROW]),
|
||||
) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
|
|
@ -93,10 +99,30 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 0
|
||||
position = POS_ADD_ROW
|
||||
onClickAdd.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[0]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = "Source",
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stringResource(state.source.stringResId))
|
||||
},
|
||||
onClick = {
|
||||
position = POS_SOURCE
|
||||
onClickSourceSettings.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -110,10 +136,10 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 1
|
||||
position = POS_SETTINGS
|
||||
onClickSettings.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -132,10 +158,10 @@ fun HomeSettingsRowList(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 2
|
||||
position = POS_PRESETS
|
||||
onClickPresets.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[2]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
|
|||
|
|
@ -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.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomePageSettingsSource
|
||||
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.ServerPluginApi
|
||||
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -76,37 +81,55 @@ class HomeSettingsViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val homeDataService: HomeDataService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
val preferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
||||
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>()
|
||||
|
||||
val discoverEnabled = seerrServerRepository.active
|
||||
|
||||
private var originalLocalSettings: HomePageSettings? = null
|
||||
private var originalRemoteSettings: HomePageSettings? = null
|
||||
private var originalSettings: HomePageSettings? = null
|
||||
|
||||
init {
|
||||
addCloseable { saveToLocal() }
|
||||
viewModelScope.launchIO {
|
||||
val user = serverRepository.currentUser ?: return@launchIO
|
||||
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
||||
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val currentSettings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
|
||||
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
|
||||
originalSettings = currentSettings.asHomePageSettings()
|
||||
Timber.v("currentSettings=%s", currentSettings)
|
||||
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
source = currentSettings.source,
|
||||
libraries = libraries,
|
||||
rows = currentSettings.rows,
|
||||
)
|
||||
|
|
@ -134,7 +157,7 @@ class HomeSettingsViewModel
|
|||
viewModelScope.async(WholphinDispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
homeDataService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
|
|
@ -496,7 +519,7 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun saveToRemote() {
|
||||
fun saveToRemote(): Job =
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
Timber.d("Saving home settings to remote")
|
||||
|
|
@ -506,6 +529,10 @@ class HomeSettingsViewModel
|
|||
try {
|
||||
Timber.d("saveToRemote")
|
||||
homeSettingsService.saveToServer(user.id, settings)
|
||||
homeSettingsService.updateCurrent(
|
||||
HomePageSettingsSource.SERVER_PROFILE,
|
||||
settings,
|
||||
)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
|
|
@ -513,7 +540,6 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromRemote() {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -530,7 +556,10 @@ class HomeSettingsViewModel
|
|||
}
|
||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = newRows)
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.SERVER_PROFILE,
|
||||
rows = newRows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No remote settings")
|
||||
|
|
@ -560,7 +589,10 @@ class HomeSettingsViewModel
|
|||
Timber.v("Got web settings")
|
||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = result.rows)
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.LOCAL,
|
||||
rows = result.rows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No web settings")
|
||||
|
|
@ -575,34 +607,19 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun saveToLocal() {
|
||||
// This uses injected ioScope so that it will still run when the page is closing
|
||||
ioScope.launchIO {
|
||||
fun saveToLocal(): Job =
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
try {
|
||||
Timber.d("saveToLocal")
|
||||
// Only save if there are changes based on original source
|
||||
val shouldSave =
|
||||
if (originalLocalSettings != null) {
|
||||
originalLocalSettings != settings
|
||||
} else if (originalRemoteSettings != null) {
|
||||
originalRemoteSettings != settings
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if (shouldSave) {
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
homeSettingsService.updateCurrent(user.id, settings)
|
||||
showSaveToast()
|
||||
} else {
|
||||
Timber.d("No changes")
|
||||
}
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
Timber.w(ex, "Overwriting local settings")
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
homeSettingsService.updateCurrent(
|
||||
HomePageSettingsSource.LOCAL,
|
||||
settings,
|
||||
)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
|
|
@ -610,17 +627,11 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
|
||||
_state.update {
|
||||
update.invoke(it)
|
||||
}
|
||||
serverRepository.currentUser?.id?.let { userId ->
|
||||
homeSettingsService.currentSettings.update {
|
||||
HomePageResolvedSettings(userId, state.value.rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resizeCards(relative: Int) {
|
||||
|
|
@ -649,12 +660,32 @@ class HomeSettingsViewModel
|
|||
|
||||
fun resetToDefault() =
|
||||
viewModelScope.launchIO {
|
||||
val userId = serverRepository.currentUser?.id ?: return@launchIO
|
||||
val user = serverRepository.currentUser ?: return@launchIO
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.createDefault(userId)
|
||||
val result =
|
||||
if (serverPluginActive.value) {
|
||||
try {
|
||||
val settings =
|
||||
homeSettingsService.fetchSettingsFor(
|
||||
user.id,
|
||||
HomePageSettingsSource.PLUGIN,
|
||||
)
|
||||
settings
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Plugin is active, but error fetching home settings")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
} ?: homeSettingsService.createDefault(user.id)
|
||||
|
||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(rows = result.rows, rowData = emptyList())
|
||||
it.copy(
|
||||
source = result.source,
|
||||
rows = result.rows,
|
||||
rowData = emptyList(),
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
|
@ -746,6 +777,10 @@ class HomeSettingsViewModel
|
|||
is HomeRowConfig.TvChannels -> {
|
||||
it.config.updateViewOptions(preset.liveTv)
|
||||
}
|
||||
|
||||
is HomeRowConfig.CustomEndpoint -> {
|
||||
it.config
|
||||
}
|
||||
}
|
||||
it.copy(config = newConfig)
|
||||
}
|
||||
|
|
@ -767,6 +802,43 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun loadFromRemotePlugin() {
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("Loading home settings from server plugin")
|
||||
try {
|
||||
// TODO should this remove local settings?
|
||||
// TODO how to set up priorities
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = serverPluginApi.fetchHomePageSettings()
|
||||
if (result != null) {
|
||||
Timber.v("Got remote settings")
|
||||
val newRows =
|
||||
result.rows.mapIndexed { index, config ->
|
||||
homeSettingsService.resolve(index, config)
|
||||
}
|
||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
source = HomePageSettingsSource.PLUGIN,
|
||||
rows = newRows,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No server plugin settings")
|
||||
showToast(context, "No server plugin settings found")
|
||||
}
|
||||
fetchRowData()
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
// TODO
|
||||
Timber.w(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfigAction(
|
||||
row: HomeRowConfigDisplay,
|
||||
action: HomeRowConfigAction,
|
||||
|
|
@ -861,10 +933,13 @@ class HomeSettingsViewModel
|
|||
fetchRowData()
|
||||
}
|
||||
}
|
||||
|
||||
fun discardChanges(): Job = Job().apply { complete() }
|
||||
}
|
||||
|
||||
data class HomePageSettingsState(
|
||||
val loading: LoadingState,
|
||||
val source: HomePageSettingsSource,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
val rowData: List<HomeRowLoadingState>,
|
||||
val libraries: List<Library>,
|
||||
|
|
@ -873,6 +948,7 @@ data class HomePageSettingsState(
|
|||
val EMPTY =
|
||||
HomePageSettingsState(
|
||||
LoadingState.Pending,
|
||||
HomePageSettingsSource.UNSET,
|
||||
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
|
||||
data class Slideshow(
|
||||
val parentId: UUID,
|
||||
val parentId: UUID?,
|
||||
val index: Int,
|
||||
val filter: CollectionFolderFilter,
|
||||
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.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
|
|
@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon(
|
|||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
NavigationDrawerItem(
|
||||
modifier = modifier,
|
||||
selected = false,
|
||||
|
|
@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon(
|
|||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
text = user.name ?: user.id.toString(),
|
||||
maxLines = 1,
|
||||
)
|
||||
|
|
@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem(
|
|||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
|
@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem(
|
|||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.enableMarquee(focused),
|
||||
text = library.name(context),
|
||||
maxLines = 1,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
|
|
@ -158,7 +159,11 @@ class PlayExternalViewModel
|
|||
is PlaylistItem.Media -> playlistItem.item
|
||||
}
|
||||
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
val subtitleStc =
|
||||
streamChoiceService.getSeriesTrackChoices(
|
||||
item.data,
|
||||
SeriesTrackChoiceType.SUBTITLE,
|
||||
)
|
||||
if (mediaSource == null) {
|
||||
Timber.w("Media source is null")
|
||||
return@launchDefault
|
||||
|
|
@ -168,13 +173,14 @@ class PlayExternalViewModel
|
|||
val subtitleIndex =
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
item = item.data,
|
||||
source = mediaSource,
|
||||
audioStream = null,
|
||||
seriesId = item.data.seriesId,
|
||||
itemPlayback = playbackConfig,
|
||||
plc = plc,
|
||||
stc = subtitleStc,
|
||||
prefs = prefs,
|
||||
)?.index
|
||||
)?.stream
|
||||
?.index
|
||||
val externalSubtitles =
|
||||
mediaSource.mediaStreams
|
||||
?.filter { it.isExternal }
|
||||
|
|
@ -237,6 +243,8 @@ class PlayExternalViewModel
|
|||
}
|
||||
|
||||
// VLC
|
||||
// A zero position alone does not override VLC's saved resume position.
|
||||
putExtra("from_start", positionMs == 0L)
|
||||
if (subtitleUrls.isNotEmpty()) {
|
||||
putExtra("subtitles_location", subtitleUrls.first().toString())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ import androidx.tv.material3.Text
|
|||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.formatMaxBitrate
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
|
||||
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
|
||||
|
|
@ -60,6 +62,7 @@ enum class PlaybackDialogType {
|
|||
PLAYBACK_SPEED,
|
||||
VIDEO_SCALE,
|
||||
SUBTITLE_DELAY,
|
||||
BITRATE,
|
||||
}
|
||||
|
||||
data class PlaybackSettings(
|
||||
|
|
@ -73,6 +76,7 @@ data class PlaybackSettings(
|
|||
val subtitleDelay: Duration,
|
||||
val hasSubtitleDownloadPermission: Boolean,
|
||||
val playbackSpeedEnabled: Boolean,
|
||||
val maxBitrate: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -162,6 +166,13 @@ fun PlaybackDialog(
|
|||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.BITRATE,
|
||||
headline = stringResource(R.string.max_bitrate),
|
||||
supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.DEBUG,
|
||||
|
|
@ -271,6 +282,29 @@ fun PlaybackDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackDialogType.BITRATE -> {
|
||||
val choices =
|
||||
AppPreference.bitrateValues.map { bitrate ->
|
||||
BottomDialogItem(
|
||||
data = bitrate,
|
||||
headline = formatMaxBitrate(bitrate),
|
||||
supporting = null,
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = choices,
|
||||
currentChoice = choices.firstOrNull { it.data == settings.maxBitrate },
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
onSelectChoice = { _, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data))
|
||||
},
|
||||
gravity = leftGravity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,10 @@ fun PlaybackPageContent(
|
|||
player.seekToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackAction.ChangeBitrate -> {
|
||||
viewModel.changeMaxBitrate(it.maxBitrate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -752,6 +756,7 @@ fun PlaybackPageContent(
|
|||
// TODO Passing through audio prevents changing playback speed
|
||||
// See https://github.com/damontecres/Wholphin/issues/164
|
||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
|
||||
maxBitrate = state.maxBitrate,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog =
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ data class PlaybackState(
|
|||
val nextUp: BaseItem? = null,
|
||||
val playlistIndex: Int = 0,
|
||||
val playlist: Playlist = Playlist(emptyList()),
|
||||
val maxBitrate: Long = -1L,
|
||||
) {
|
||||
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
|
||||
val hasPrevious: Boolean get() = playlistIndex > 0
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.media.MediaCodecList
|
|||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
|
@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.Chapter
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
|
|
@ -198,7 +200,7 @@ class PlaybackViewModel
|
|||
|
||||
val currentUserDto = serverRepository.currentUserDtoFlow
|
||||
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
val initJob: Job
|
||||
|
||||
init {
|
||||
|
|
@ -290,8 +292,16 @@ class PlaybackViewModel
|
|||
*/
|
||||
private suspend fun init() {
|
||||
musicService.stop()
|
||||
_state.update { it.copy(nextUp = null) }
|
||||
this.preferences = userPreferencesService.getCurrent()
|
||||
val maxBitrate =
|
||||
preferences.appPreferences.playbackPreferences.maxBitrate
|
||||
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
|
||||
_state.update {
|
||||
it.copy(
|
||||
nextUp = null,
|
||||
maxBitrate = maxBitrate,
|
||||
)
|
||||
}
|
||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||
addCloseable { refreshRateService.resetRefreshRate() }
|
||||
}
|
||||
|
|
@ -489,7 +499,16 @@ class PlaybackViewModel
|
|||
null
|
||||
}
|
||||
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
|
||||
val audioStc =
|
||||
streamChoiceService.getSeriesTrackChoices(
|
||||
base,
|
||||
SeriesTrackChoiceType.AUDIO,
|
||||
)
|
||||
val subtitleStc =
|
||||
streamChoiceService.getSeriesTrackChoices(
|
||||
base,
|
||||
SeriesTrackChoiceType.AUDIO,
|
||||
)
|
||||
|
||||
if (mediaSource == null && !isLiveTv) {
|
||||
showToast(
|
||||
|
|
@ -518,30 +537,31 @@ class PlaybackViewModel
|
|||
|
||||
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
|
||||
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
|
||||
val audioStream =
|
||||
val audioResult =
|
||||
mediaSource?.let {
|
||||
streamChoiceService
|
||||
.chooseAudioStream(
|
||||
source = mediaSource,
|
||||
seriesId = base.seriesId,
|
||||
item = base,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
stc = audioStc,
|
||||
prefs = preferences,
|
||||
)
|
||||
}
|
||||
val audioIndex = audioStream?.index
|
||||
val audioIndex = audioResult?.stream?.index
|
||||
|
||||
val subtitleIndex =
|
||||
mediaSource?.let {
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
item = base,
|
||||
source = mediaSource,
|
||||
audioStream = audioStream,
|
||||
seriesId = base.seriesId,
|
||||
audioStream = audioResult?.stream,
|
||||
itemPlayback = itemPlayback,
|
||||
plc = plc,
|
||||
stc = subtitleStc,
|
||||
prefs = preferences,
|
||||
)?.index
|
||||
)?.stream
|
||||
?.index
|
||||
}
|
||||
Timber.d(
|
||||
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
|
||||
|
|
@ -616,6 +636,7 @@ class PlaybackViewModel
|
|||
positionMs: Long = 0,
|
||||
enableDirectPlay: Boolean = !this.forceTranscoding,
|
||||
enableDirectStream: Boolean = !this.forceTranscoding,
|
||||
maxBitrate: Long = this.state.value.maxBitrate,
|
||||
): Unit =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val itemId = item.id
|
||||
|
|
@ -626,7 +647,8 @@ class PlaybackViewModel
|
|||
state.value.currentPlayback?.let { currentPlayback ->
|
||||
if (currentPlayback.item.id == item.id &&
|
||||
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
|
||||
enableDirectPlay
|
||||
enableDirectPlay &&
|
||||
state.value.maxBitrate == maxBitrate
|
||||
) {
|
||||
val wasSuccessful =
|
||||
changeStreamsDirectPlay(
|
||||
|
|
@ -639,18 +661,17 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
Timber.i(
|
||||
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
|
||||
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s",
|
||||
itemId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
enableDirectPlay,
|
||||
enableDirectStream,
|
||||
positionMs,
|
||||
maxBitrate,
|
||||
)
|
||||
_state.update { it.copy(maxBitrate = maxBitrate) }
|
||||
|
||||
val maxBitrate =
|
||||
preferences.appPreferences.playbackPreferences.maxBitrate
|
||||
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
|
||||
val response by
|
||||
api.mediaInfoApi
|
||||
.getPostedPlaybackInfo(
|
||||
|
|
@ -696,6 +717,7 @@ class PlaybackViewModel
|
|||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = itemId,
|
||||
mediaSourceId = source.id,
|
||||
liveStreamId = source.liveStreamId,
|
||||
static = true,
|
||||
tag = source.eTag,
|
||||
playSessionId = response.playSessionId,
|
||||
|
|
@ -955,7 +977,7 @@ class PlaybackViewModel
|
|||
streamChoiceService.resolveSubtitleIndex(
|
||||
source = currentPlayback.mediaSourceInfo,
|
||||
audioStreamIndex = index,
|
||||
seriesId = currentItem.item.data.seriesId,
|
||||
item = currentItem.item.data,
|
||||
subtitleIndex = currentPlayback.subtitleIndex,
|
||||
prefs = preferences,
|
||||
)
|
||||
|
|
@ -991,7 +1013,7 @@ class PlaybackViewModel
|
|||
streamChoiceService.resolveSubtitleIndex(
|
||||
source = currentPlayback.mediaSourceInfo,
|
||||
audioStreamIndex = currentPlayback.audioIndex,
|
||||
seriesId = currentItem.item.data.seriesId,
|
||||
item = currentItem.item.data,
|
||||
subtitleIndex = index,
|
||||
prefs = preferences,
|
||||
)
|
||||
|
|
@ -1009,6 +1031,26 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun changeMaxBitrate(newMaxBitrate: Long) {
|
||||
viewModelScope.launchIO {
|
||||
val currentPlayback = state.value.currentPlayback
|
||||
if (currentPlayback != null) {
|
||||
Timber.d("Changing bitrate to %s", newMaxBitrate)
|
||||
changeStreams(
|
||||
item = currentItem.item,
|
||||
sourceId = currentPlayback.mediaSourceInfo.id,
|
||||
audioIndex = currentPlayback.audioIndex,
|
||||
subtitleIndex = currentPlayback.subtitleIndex,
|
||||
positionMs = onMain { player.currentPosition },
|
||||
enableDirectPlay = true,
|
||||
maxBitrate = newMaxBitrate,
|
||||
)
|
||||
} else {
|
||||
Timber.w("Trying to change bitrate, but currentPlayback is null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun saveTrackSelection(
|
||||
trackIndex: Int,
|
||||
type: MediaStreamType,
|
||||
|
|
@ -1110,7 +1152,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
// Variables for tracking segment state
|
||||
// Exposed for testing
|
||||
@VisibleForTesting
|
||||
internal var segmentJob: Job? = null
|
||||
private val autoSkippedSegments = mutableSetOf<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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -122,6 +123,10 @@ sealed interface PlaybackAction {
|
|||
data object Previous : PlaybackAction
|
||||
|
||||
data object Next : PlaybackAction
|
||||
|
||||
data class ChangeBitrate(
|
||||
val maxBitrate: Long,
|
||||
) : PlaybackAction
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -561,12 +566,19 @@ fun <T> BottomDialog(
|
|||
gravity: Int,
|
||||
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 bringIntoViewRequesters =
|
||||
remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
|
||||
if (currentChoice != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
choices.indexOfFirstOrNull { it == currentChoice }?.let {
|
||||
focusRequesters.getOrNull(it)?.tryRequestFocus()
|
||||
}
|
||||
bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
|
||||
focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
// TODO enforcing a width ends up ignore the gravity
|
||||
|
|
@ -591,6 +603,7 @@ fun <T> BottomDialog(
|
|||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -624,7 +637,10 @@ fun <T> BottomDialog(
|
|||
}
|
||||
},
|
||||
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
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
|
@ -38,9 +45,53 @@ enum class PreferenceScreenOption {
|
|||
SCREENSAVER,
|
||||
SKIP_SEGMENTS,
|
||||
EXPERIMENTAL,
|
||||
HDR_FORMATS,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSeerrDialogBody(
|
||||
user: SeerrUser,
|
||||
server: SeerrServer,
|
||||
pluginProvided: Boolean,
|
||||
ex: Exception? = null,
|
||||
): String {
|
||||
val username =
|
||||
when (user.authMethod) {
|
||||
SeerrAuthMethod.LOCAL,
|
||||
SeerrAuthMethod.JELLYFIN,
|
||||
-> user.username ?: stringResource(R.string.unknown)
|
||||
|
||||
SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key)
|
||||
}
|
||||
val method =
|
||||
when (user.authMethod) {
|
||||
SeerrAuthMethod.LOCAL -> R.string.seerr_local_user
|
||||
SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user
|
||||
SeerrAuthMethod.API_KEY -> 0
|
||||
}.let {
|
||||
if (it != 0) " (${stringResource(it)})" else ""
|
||||
}
|
||||
|
||||
val body =
|
||||
"""
|
||||
${stringResource(R.string.url)}: ${server.url}
|
||||
${stringResource(R.string.username)}: $username$method
|
||||
""".trimIndent() +
|
||||
if (pluginProvided) {
|
||||
"\n\n${stringResource(R.string.managed_by_server)}"
|
||||
} else {
|
||||
""
|
||||
} +
|
||||
if (ex != null) {
|
||||
"\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return remember { body }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
|
|
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
||||
import com.github.damontecres.wholphin.preferences.HdrFormatPreferences
|
||||
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
|
|
@ -74,9 +77,11 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.ui.serverSupportedMessage
|
||||
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
|
||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -98,6 +103,7 @@ fun PreferencesContent(
|
|||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -110,6 +116,11 @@ fun PreferencesContent(
|
|||
var showPinFlow by remember { mutableStateOf(false) }
|
||||
var showVersionDialog by remember { mutableStateOf(false) }
|
||||
val players by viewModel.externalPlayers.collectAsState()
|
||||
val serverSupported by viewModel.serverSupported.collectAsState()
|
||||
val serverVersionMessage =
|
||||
remember(resources, serverSupported) {
|
||||
resources.serverSupportedMessage(serverSupported.second, serverSupported.first)
|
||||
}
|
||||
|
||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
||||
|
|
@ -155,6 +166,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
|
||||
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
|
||||
PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences
|
||||
}
|
||||
val screenTitle =
|
||||
when (preferenceScreenOption) {
|
||||
|
|
@ -165,6 +177,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
|
||||
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
|
||||
PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats
|
||||
}
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
|
@ -224,6 +237,19 @@ fun PreferencesContent(
|
|||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
serverVersionMessage?.let { message ->
|
||||
item {
|
||||
ListItem(
|
||||
selected = false,
|
||||
enabled = true,
|
||||
onClick = {},
|
||||
headlineContent = {
|
||||
PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showUpdate) {
|
||||
item {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
|
@ -259,6 +285,7 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
prefList.forEachIndexed { groupIndex, group ->
|
||||
item {
|
||||
Text(
|
||||
|
|
@ -475,10 +502,11 @@ fun PreferencesContent(
|
|||
title = stringResource(pref.title),
|
||||
onClick = {
|
||||
seerrDialogMode =
|
||||
when (val conn = seerrConnection) {
|
||||
when (val conn = seerrConnection.connection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
SeerrDialogMode.Error(
|
||||
conn.server.url,
|
||||
conn.server,
|
||||
conn.user,
|
||||
conn.ex,
|
||||
)
|
||||
}
|
||||
|
|
@ -489,22 +517,20 @@ fun PreferencesContent(
|
|||
|
||||
is SeerrConnectionStatus.Success -> {
|
||||
SeerrDialogMode.Remove(
|
||||
conn.current.server.url,
|
||||
conn.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = focusModifier,
|
||||
summary =
|
||||
when (seerrConnection) {
|
||||
when (seerrConnection.connection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
stringResource(R.string.voice_error_server)
|
||||
}
|
||||
|
||||
SeerrConnectionStatus.NotConfigured -> {
|
||||
stringResource(
|
||||
R.string.add_server,
|
||||
)
|
||||
stringResource(R.string.add_server)
|
||||
}
|
||||
|
||||
is SeerrConnectionStatus.Success -> {
|
||||
|
|
@ -699,63 +725,14 @@ 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,
|
||||
SeerrDialog(
|
||||
seerrDialogMode = seerrDialogMode,
|
||||
seerrConnection = seerrConnection,
|
||||
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
|
||||
seerrVm = seerrVm,
|
||||
)
|
||||
}
|
||||
|
||||
is SeerrDialogMode.Error -> {
|
||||
val errorStr = stringResource(R.string.voice_error_server)
|
||||
val body =
|
||||
remember(mode) {
|
||||
"""
|
||||
${mode.serverUrl}
|
||||
|
||||
$errorStr: ${mode.ex.localizedMessage}
|
||||
""".trimIndent()
|
||||
}
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = body,
|
||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
seerrDialogMode = SeerrDialogMode.None
|
||||
},
|
||||
bodyColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrDialogMode.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (showQuickConnectDialog) {
|
||||
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
|
||||
|
|
@ -838,6 +815,7 @@ fun PreferencesPage(
|
|||
PreferenceScreenOption.SCREENSAVER,
|
||||
PreferenceScreenOption.SKIP_SEGMENTS,
|
||||
PreferenceScreenOption.EXPERIMENTAL,
|
||||
PreferenceScreenOption.HDR_FORMATS,
|
||||
-> {
|
||||
PreferencesContent(
|
||||
initialPreferences,
|
||||
|
|
@ -857,18 +835,3 @@ data class CacheUsage(
|
|||
val imageMemoryMax: Long,
|
||||
val imageDiskUsed: Long,
|
||||
)
|
||||
|
||||
private sealed interface SeerrDialogMode {
|
||||
data object None : SeerrDialogMode
|
||||
|
||||
data object Add : SeerrDialogMode
|
||||
|
||||
data class Remove(
|
||||
val serverUrl: String,
|
||||
) : SeerrDialogMode
|
||||
|
||||
data class Error(
|
||||
val serverUrl: String,
|
||||
val ex: Exception,
|
||||
) : SeerrDialogMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
|
|||
import com.github.damontecres.wholphin.services.ServerReportService
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
|
@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -76,6 +79,23 @@ class PreferencesViewModel
|
|||
|
||||
val externalPlayers = MutableStateFlow<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 {
|
||||
viewModelScope.launchIO {
|
||||
val fakeIntent =
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.Surface
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
|
|
@ -177,7 +178,12 @@ fun ServerIconCard(
|
|||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
|
||||
tint = MaterialTheme.colorScheme.errorContainer,
|
||||
tint =
|
||||
if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
|
||||
AppColors.GoldenYellow
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
modifier = Modifier.size(cardSize * 0.3f),
|
||||
)
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically
|
|||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -66,11 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
|||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.serverSupportedMessage
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
|
|
@ -101,6 +103,8 @@ private fun SwitchServerContentInternal(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
val focusRequesters =
|
||||
remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } }
|
||||
var showAddServer by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
|
||||
var focusedIndex by rememberInt(0)
|
||||
|
|
@ -144,10 +148,13 @@ private fun SwitchServerContentInternal(
|
|||
val firstServerFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.loading) {
|
||||
val state = state
|
||||
if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) {
|
||||
firstServerFocus.tryRequestFocus()
|
||||
} else if (state.loading == LoadingState.Pending) {
|
||||
firstServerFocus.tryRequestFocus()
|
||||
when (state.loading) {
|
||||
is LoadingState.Error,
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> Unit
|
||||
|
||||
LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
|
|
@ -188,10 +195,7 @@ private fun SwitchServerContentInternal(
|
|||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = index
|
||||
}.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(firstServerFocus),
|
||||
),
|
||||
}.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
}
|
||||
// Add Server card - always rightmost
|
||||
|
|
@ -202,10 +206,7 @@ private fun SwitchServerContentInternal(
|
|||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = -1
|
||||
}.ifElse(
|
||||
state.servers.isEmpty(),
|
||||
Modifier.focusRequester(firstServerFocus),
|
||||
),
|
||||
}.focusRequester(focusRequesters.last()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -235,20 +236,7 @@ private fun SwitchServerContentInternal(
|
|||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
) { message ->
|
||||
|
||||
Text(
|
||||
text = message ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||
.align(Alignment.BottomCenter),
|
||||
)
|
||||
SwitchErrorMessage(message ?: "")
|
||||
}
|
||||
|
||||
// Delete server dialog
|
||||
|
|
@ -327,12 +315,12 @@ private fun AddServerDialog(
|
|||
}
|
||||
}
|
||||
|
||||
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
|
||||
val buttonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// Default focus to first discovered server if available
|
||||
LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) {
|
||||
if (!showEnterAddress && state.discoveredServers.isNotEmpty()) {
|
||||
firstDiscoveredServerFocusRequester.tryRequestFocus()
|
||||
LaunchedEffect(showEnterAddress) {
|
||||
if (!showEnterAddress) {
|
||||
buttonFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,12 +367,8 @@ private fun AddServerDialog(
|
|||
key = { state.discoveredServers[it].url },
|
||||
) { index ->
|
||||
val server = state.discoveredServers[index]
|
||||
val focusRequester =
|
||||
if (index == 0) {
|
||||
firstDiscoveredServerFocusRequester
|
||||
} else {
|
||||
remember { FocusRequester() }
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() }
|
||||
|
||||
ListItem(
|
||||
enabled = true,
|
||||
|
|
@ -491,21 +475,38 @@ fun serverErrorMessage(
|
|||
server: ServerState?,
|
||||
resources: Resources,
|
||||
): String? =
|
||||
when {
|
||||
server?.status is ServerConnectionStatus.Error -> {
|
||||
server.status.message
|
||||
}
|
||||
|
||||
server?.status is ServerConnectionStatus.Success &&
|
||||
server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
|
||||
resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}"
|
||||
}
|
||||
|
||||
server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
|
||||
resources.getString(R.string.server_version_not_supported)
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (server == null || server.status == ServerConnectionStatus.Pending) {
|
||||
null
|
||||
} else if (server.status is ServerConnectionStatus.Success) {
|
||||
resources.serverSupportedMessage(
|
||||
server.versionSupported,
|
||||
server.status.systemInfo.version
|
||||
?.let { ServerVersion.fromString(it) },
|
||||
)
|
||||
} else if (server.status is ServerConnectionStatus.Error) {
|
||||
server.status.message
|
||||
} else {
|
||||
resources.serverSupportedMessage(
|
||||
server.versionSupported,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.SwitchErrorMessage(message: String?) {
|
||||
message?.let {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||
.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ class SwitchServerViewModel
|
|||
result.systemInfo.version?.let { ServerVersion.fromString(it) }
|
||||
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
|
||||
ServerVersionSupported.NOT_SUPPORTED
|
||||
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
|
||||
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
|
||||
} else {
|
||||
ServerVersionSupported.SUPPORTED
|
||||
}
|
||||
|
|
@ -307,4 +309,5 @@ enum class ServerVersionSupported {
|
|||
SUPPORTED,
|
||||
NOT_SUPPORTED,
|
||||
UNKNOWN,
|
||||
NOT_SUPPORTED_IN_FUTURE,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction
|
|||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton
|
|||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.serverSupportedMessage
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -183,37 +183,15 @@ fun SwitchUserContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
when (state.serverVersionSupported) {
|
||||
ServerVersionSupported.SUPPORTED -> {}
|
||||
|
||||
ServerVersionSupported.NOT_SUPPORTED,
|
||||
ServerVersionSupported.UNKNOWN,
|
||||
-> {
|
||||
val resources = LocalResources.current
|
||||
val message =
|
||||
remember(resources) {
|
||||
if (state.serverVersion.isNotNullOrBlank()) {
|
||||
resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}"
|
||||
} else {
|
||||
resources.getString(R.string.server_version_not_supported) + ": " +
|
||||
resources.getString(R.string.unknown)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||
.align(Alignment.BottomCenter),
|
||||
remember(resources, state.serverVersionSupported) {
|
||||
resources.serverSupportedMessage(
|
||||
state.serverVersionSupported,
|
||||
state.serverVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
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