mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
Add option to override user profile settings for audio & subtitle languages (#1680)
## Description Adds settings under the "User profile settings" section which allows for specifying preferred languages for audio and subtitles and the preferred subtitle mode. By default (matching existing Wholphin behavior), the preferred languages and subtitle mode are derived from the user's profile settings provided by the server which can be set on the WebUI: User->Settings->Playback or Subtitles. However, this isn't well understood and Wholphin doesn't display these values anywhere, so many users were confused. The preferred languages and subtitle mode can now be configured from within Wholphin. Setting these will **not** update the server. The settings are **only** used locally. ### Related issues Related to #319 Closes #790 ### Testing Emulator manually changing some settings, added additional unit tests to ensure the overrides are respected when choosing tracks ## Screenshots ### Settings page <img width="1280" height="720" alt="settings_01 Large" src="https://github.com/user-attachments/assets/28cc2f6b-3a60-42a4-b268-48695086d566" /> ### Clicking into "More user profile settings" <img width="1280" height="720" alt="settings_02 Large" src="https://github.com/user-attachments/assets/6c3be3aa-c177-4d44-8060-b76d15fb10d7" /> ### Language choice dialog Can filter languages by typing in the search box at the top <img width="1280" height="720" alt="settings_03 Large" src="https://github.com/user-attachments/assets/c014d0ba-8d5b-4201-a6ee-4876b478148c" /> ## AI or LLM usage None
This commit is contained in:
parent
077b7e4570
commit
3f60fa7550
26 changed files with 1751 additions and 48 deletions
|
|
@ -0,0 +1,741 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 35,
|
||||
"identityHash": "ff9ade58d4ac39e36335ac0df1405a5f",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, 'ff9ade58d4ac39e36335ac0df1405a5f')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.SetupDestination
|
|||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||
|
|
@ -93,6 +94,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||
|
||||
@Inject
|
||||
lateinit var userPreferencesService: UserPreferencesService
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
|
@ -226,8 +230,8 @@ class MainActivity : AppCompatActivity() {
|
|||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
if (appPreferences == null) {
|
||||
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
||||
if (userPreferences == null) {
|
||||
// Show loading page if it is taking a while to get app preferences
|
||||
var showLoading by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -246,7 +250,8 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
appPreferences?.let { appPreferences ->
|
||||
userPreferences?.let { userPreferences ->
|
||||
val appPreferences = userPreferences.appPreferences
|
||||
CoilConfig(
|
||||
prefs = appPreferences,
|
||||
okHttpClient = okHttpClient,
|
||||
|
|
@ -265,7 +270,7 @@ class MainActivity : AppCompatActivity() {
|
|||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
userPreferences = userPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
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.NavigationManager
|
||||
|
|
@ -44,12 +43,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
|||
fun MainContent(
|
||||
backStack: MutableList<SetupDestination>,
|
||||
navigationManager: NavigationManager,
|
||||
appPreferences: AppPreferences,
|
||||
userPreferences: UserPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||
val preferences by rememberUpdatedState(userPreferences)
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
|
|
@ -59,7 +58,7 @@ fun MainContent(
|
|||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||
// setupNavigationManager.backStack = backStack
|
||||
val interfaceCustomization =
|
||||
remember(appPreferences) { InterfaceCustomization(appPreferences) }
|
||||
remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) }
|
||||
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
|
|
@ -105,7 +104,7 @@ fun MainContent(
|
|||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!appPreferences.signInAutomatically) {
|
||||
if (!userPreferences.appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +139,7 @@ fun MainContent(
|
|||
screenSaverState.show,
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
AppScreensaver(appPreferences, Modifier.fillMaxSize())
|
||||
AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import java.util.UUID
|
|||
SeerrUser::class,
|
||||
RememberedTab::class,
|
||||
],
|
||||
version = 34,
|
||||
version = 35,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -67,6 +67,7 @@ import java.util.UUID
|
|||
AutoMigration(31, 32),
|
||||
AutoMigration(32, 33),
|
||||
AutoMigration(33, 34),
|
||||
AutoMigration(34, 35),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.room.Update
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
|
|
@ -56,6 +57,12 @@ interface JellyfinServerDao {
|
|||
userId: UUID,
|
||||
): JellyfinUser?
|
||||
|
||||
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||
fun getUserFlow(
|
||||
serverId: UUID,
|
||||
userId: UUID,
|
||||
): Flow<JellyfinUser?>
|
||||
|
||||
@Query("DELETE FROM servers WHERE id = :serverId")
|
||||
fun deleteServer(serverId: UUID)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ import com.github.damontecres.wholphin.ui.toServerString
|
|||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -57,7 +60,18 @@ class ServerRepository
|
|||
val currentServer: JellyfinServer? get() = _current.value?.server
|
||||
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
||||
val currentUser: JellyfinUser? get() = _current.value?.user
|
||||
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val currentUserFlow: Flow<JellyfinUser?>
|
||||
get() =
|
||||
_current
|
||||
.flatMapLatest { user ->
|
||||
if (user?.user != null) {
|
||||
serverDao.getUserFlow(user.user.serverId, user.user.id)
|
||||
} else {
|
||||
flow { emit(null) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import androidx.room.Index
|
|||
import androidx.room.PrimaryKey
|
||||
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.ui.isNotNullOrBlank
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -65,11 +67,15 @@ data class JellyfinUser(
|
|||
val requireLogin: Boolean = false,
|
||||
val lastUsed: ZonedDateTime? = null,
|
||||
val uiLanguage: String? = null,
|
||||
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||
) {
|
||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||
|
||||
override fun toString(): String =
|
||||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
||||
"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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,3 +89,13 @@ data class JellyfinServerUsers(
|
|||
)
|
||||
val users: List<JellyfinUser>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JellyfinUserPreferences(
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
@ColumnInfo(defaultValue = "USE_USER_PROFILE")
|
||||
val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -582,6 +582,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
|
||||
)
|
||||
|
||||
val UserProfileSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.override_user_profile_settings,
|
||||
destination = Destination.UserAppPreferences,
|
||||
summary = R.string.override_user_profile_settings_summary,
|
||||
)
|
||||
|
||||
val SkipIntros =
|
||||
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
|
||||
title = R.string.skip_intro_behavior,
|
||||
|
|
@ -1116,6 +1123,7 @@ val basicPreferences =
|
|||
AppPreference.CustomizeHome,
|
||||
AppPreference.UserPinnedNavDrawerItems,
|
||||
AppPreference.UserInterfaceLanguage,
|
||||
AppPreference.UserProfileSettings,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.preferences
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration
|
|||
*/
|
||||
data class UserPreferences(
|
||||
val appPreferences: AppPreferences,
|
||||
val userPreferences: JellyfinUserPreferences?,
|
||||
)
|
||||
|
||||
val DefaultUserConfiguration =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package com.github.damontecres.wholphin.preferences
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||
|
||||
/**
|
||||
* Represents settings from the user's profile on the server that may be overridden within the app
|
||||
*/
|
||||
object UserProfileSettings {
|
||||
/**
|
||||
* Special value that means the preferred language should be taken from the user's profile on the server
|
||||
*/
|
||||
const val USE_USER_PROFILE = ""
|
||||
|
||||
/**
|
||||
* Special value that means the user has no preferred language
|
||||
*/
|
||||
const val PREFER_ANY_LANGUAGE = "_any-language"
|
||||
|
||||
val PreferredAudioLang =
|
||||
AppClickablePreference<JellyfinUserPreferences>(
|
||||
title = R.string.preferred_audio_language,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val PreferredSubtitleLang =
|
||||
AppClickablePreference<JellyfinUserPreferences>(
|
||||
title = R.string.preferred_subtitle_language,
|
||||
summary = null,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val SubtitleModePref =
|
||||
AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>(
|
||||
title = R.string.subtitle_mode,
|
||||
defaultValue = SubtitleModePreference.USE_USER_PROFILE,
|
||||
getter = { it.subtitleMode },
|
||||
setter = { prefs, value ->
|
||||
prefs.copy(subtitleMode = value)
|
||||
},
|
||||
displayValues = R.array.subtitle_mode_options,
|
||||
indexToValue = { SubtitleModePreference.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val Preferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.profile_specific_settings_from_server,
|
||||
preferences =
|
||||
listOf(
|
||||
PreferredAudioLang,
|
||||
PreferredSubtitleLang,
|
||||
SubtitleModePref,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
enum class SubtitleModePreference {
|
||||
USE_USER_PROFILE,
|
||||
DEFAULT,
|
||||
SMART,
|
||||
ONLY_FORCED,
|
||||
ALWAYS,
|
||||
NONE,
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
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.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
|
@ -135,7 +137,17 @@ class StreamChoiceService
|
|||
val seriesLang =
|
||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
// If the user has chosen a different language for the series, prefer that
|
||||
val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
|
||||
val audioLanguage =
|
||||
if (seriesLang != null) {
|
||||
seriesLang
|
||||
} else {
|
||||
val pref = prefs.userPreferences?.preferredAudioLanguage
|
||||
when (pref) {
|
||||
UserProfileSettings.USE_USER_PROFILE -> userConfig?.audioLanguagePreference
|
||||
UserProfileSettings.PREFER_ANY_LANGUAGE -> null
|
||||
else -> pref
|
||||
}
|
||||
}
|
||||
|
||||
if (audioLanguage.isNotNullOrBlank()) {
|
||||
val sorted =
|
||||
|
|
@ -226,23 +238,26 @@ class StreamChoiceService
|
|||
): MediaStream? {
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
return null
|
||||
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
}
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
if (seriesLang != null) {
|
||||
seriesLang
|
||||
} else {
|
||||
val pref = prefs.userPreferences?.preferredSubtitleLanguage
|
||||
when (pref) {
|
||||
UserProfileSettings.USE_USER_PROFILE -> userConfig?.subtitleLanguagePreference
|
||||
UserProfileSettings.PREFER_ANY_LANGUAGE -> null
|
||||
else -> pref
|
||||
}?.takeIf { it.isNotNullOrBlank() }
|
||||
}
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
// Client-side manual override: User selected "Only Forced" in player menu
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||
?.takeIf { it.isNotNullOrBlank() }
|
||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} else {
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||
?.takeIf { it.isNotNullOrBlank() }
|
||||
|
||||
val subtitleMode =
|
||||
when {
|
||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
||||
|
|
@ -258,9 +273,17 @@ class StreamChoiceService
|
|||
|
||||
else -> {
|
||||
// Fallback to the user's preference
|
||||
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
|
||||
when (prefs.userPreferences?.subtitleMode) {
|
||||
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
|
||||
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
|
||||
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
|
||||
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
|
||||
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
|
||||
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
|
||||
null -> SubtitlePlaybackMode.DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: SubtitlePlaybackMode.DEFAULT
|
||||
val candidates =
|
||||
candidates
|
||||
.sortedWith(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -19,13 +20,10 @@ class UserPreferencesService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto!!.configuration.let { userConfig ->
|
||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
UserPreferences(
|
||||
appPrefs,
|
||||
)
|
||||
val flow: Flow<UserPreferences> =
|
||||
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
|
||||
UserPreferences(appPrefs, user?.appPreferences)
|
||||
}
|
||||
|
||||
suspend fun getCurrent(): UserPreferences = flow.first()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -448,3 +448,15 @@ fun <T> Flow<T>.collectLatestIn(
|
|||
* Easy way to combine two flows into a [Pair]
|
||||
*/
|
||||
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||
|
||||
/**
|
||||
* Easy way to combine three flows into a [Triple]
|
||||
*/
|
||||
fun <T1, T2, T3> Flow<T1>.combineTriple(
|
||||
flow: Flow<T2>,
|
||||
flow2: Flow<T3>,
|
||||
): Flow<Triple<T1, T2, T3>> =
|
||||
combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||
.combine(flow2) { (t1, t2), t3 ->
|
||||
Triple(t1, t2, t3)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ fun NowPlayingPage(
|
|||
.collectAsState(
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
null,
|
||||
),
|
||||
).value.appPreferences
|
||||
val musicPrefs = preferences.musicPreferences
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ sealed class Destination(
|
|||
val hdr: Boolean,
|
||||
) : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object UserAppPreferences : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object Search : Destination()
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.playback.PlayExternalPage
|
|||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage
|
||||
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
|
||||
import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -128,6 +129,10 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
Destination.UserAppPreferences -> {
|
||||
UserProfilePreferencesPage(modifier)
|
||||
}
|
||||
|
||||
is Destination.SeriesOverview -> {
|
||||
SeriesOverview(
|
||||
preferences = preferences,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ fun PreferencesContent(
|
|||
val state = rememberLazyListState()
|
||||
var preferences by remember { mutableStateOf(initialPreferences) }
|
||||
val currentUser by viewModel.currentUser.collectAsState()
|
||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||
var showPinFlow by remember { mutableStateOf(false) }
|
||||
var showVersionDialog by remember { mutableStateOf(false) }
|
||||
val players by viewModel.externalPlayers.collectAsState()
|
||||
|
|
|
|||
|
|
@ -57,13 +57,18 @@ class PreferencesViewModel
|
|||
private val clientInfo: ClientInfo,
|
||||
private val updateChecker: UpdateChecker,
|
||||
) : ViewModel() {
|
||||
val currentUser
|
||||
get() =
|
||||
serverRepository.currentUserFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
val currentUser =
|
||||
serverRepository.currentUserFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
val currentUserDto =
|
||||
serverRepository.currentUserDtoFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
|
||||
val seerrConnection = seerrServerRepository.connection
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.user
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* A filterable list of language choices ([PreferredLanguageType])
|
||||
*/
|
||||
@Composable
|
||||
fun FilterableLanguagePreference(
|
||||
@StringRes title: Int,
|
||||
selectedOption: PreferredLanguageType,
|
||||
options: List<PreferredLanguageType>,
|
||||
onClickOption: (PreferredLanguageType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
var filteredOptions by remember { mutableStateOf(options) }
|
||||
LaunchedEffect(query) {
|
||||
delay(500.milliseconds)
|
||||
if (query.isNotNullOrBlank()) {
|
||||
withContext(WholphinDispatchers.Default) {
|
||||
val q = query.lowercase()
|
||||
filteredOptions =
|
||||
options
|
||||
.filter {
|
||||
// Only show languages, not "built-in" types
|
||||
it is PreferredLanguageType.Language &&
|
||||
(it.name.lowercase().contains(q) || it.iso.contains(q))
|
||||
}.sortedByDescending {
|
||||
// User is likely typing the first letters of a language, so show those first
|
||||
// IE, for q="en", show English before Armenian and Bengali
|
||||
(it as PreferredLanguageType.Language).name.lowercase().startsWith(q)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredOptions = options
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val listState = rememberLazyListState()
|
||||
val focusRequesters = remember { List(filteredOptions.size) { FocusRequester() } }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.firstOrNull()?.tryRequestFocus()
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
item {
|
||||
SearchEditTextBox(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
onSearchClick = { focusRequesters.firstOrNull()?.tryRequestFocus() },
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
itemsIndexed(filteredOptions) { index, option ->
|
||||
if (option is PreferredLanguageType.Divider) {
|
||||
HorizontalDivider()
|
||||
} else {
|
||||
ListItem(
|
||||
selected = false,
|
||||
onClick = {
|
||||
onClickOption.invoke(option)
|
||||
},
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(option == selectedOption)
|
||||
},
|
||||
headlineContent = {
|
||||
val text =
|
||||
when (option) {
|
||||
PreferredLanguageType.AnyLanguage -> option.displayString.getString()
|
||||
is PreferredLanguageType.Language -> option.displayString.getString()
|
||||
is PreferredLanguageType.ServerProfile -> stringResource(R.string.use_user_profile)
|
||||
}
|
||||
Text(text)
|
||||
},
|
||||
supportingContent =
|
||||
when (option) {
|
||||
PreferredLanguageType.AnyLanguage,
|
||||
is PreferredLanguageType.Language,
|
||||
-> {
|
||||
null
|
||||
}
|
||||
|
||||
is PreferredLanguageType.ServerProfile -> {
|
||||
option.name?.let {
|
||||
{
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.user
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.util.ConcatStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.EmptyStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringProvider
|
||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||
|
||||
/**
|
||||
* UI information for displaying a preferred language
|
||||
*/
|
||||
sealed interface PreferredLanguageType {
|
||||
val displayString: StringProvider
|
||||
|
||||
data class ServerProfile(
|
||||
val name: String?,
|
||||
) : PreferredLanguageType {
|
||||
override val displayString: StringProvider
|
||||
get() =
|
||||
ConcatStringProvider(
|
||||
" - ",
|
||||
buildList {
|
||||
add(ResStringProvider(R.string.use_user_profile))
|
||||
if (name.isNotNullOrBlank()) {
|
||||
add(StringStringProvider(name))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data object AnyLanguage : PreferredLanguageType {
|
||||
override val displayString: StringProvider
|
||||
get() = ResStringProvider(R.string.any_language)
|
||||
}
|
||||
|
||||
data class Language(
|
||||
val iso: String,
|
||||
val name: String,
|
||||
) : PreferredLanguageType {
|
||||
override val displayString: StringProvider
|
||||
get() = StringStringProvider(name)
|
||||
}
|
||||
|
||||
data object Divider : PreferredLanguageType {
|
||||
override val displayString: StringProvider
|
||||
get() = EmptyStringProvider
|
||||
}
|
||||
}
|
||||
|
||||
data class PreferredLanguage(
|
||||
val selected: PreferredLanguageType = PreferredLanguageType.ServerProfile(null),
|
||||
val options: List<PreferredLanguageType> = emptyList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.user
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.ui.combineTriple
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.localizationApi
|
||||
import org.jellyfin.sdk.model.api.CultureDto
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UserPreferencesViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
val preferenceDataStore: DataStore<AppPreferences>,
|
||||
val navigationManager: NavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
val screensaverService: ScreensaverService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
val currentUser = serverRepository.currentUserFlow
|
||||
val currentUserDto =
|
||||
serverRepository.currentUserDtoFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
private val serverLanguages = MutableStateFlow<List<CultureDto>>(emptyList())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val userAppPreferences: StateFlow<JellyfinUserPreferences> =
|
||||
currentUser.mapNotNull { it?.appPreferences }.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
JellyfinUserPreferences(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun createLanguageFlow(isAudio: Boolean): StateFlow<PreferredLanguage> =
|
||||
currentUser
|
||||
.combineTriple(serverRepository.currentUserDtoFlow, serverLanguages)
|
||||
.mapLatest { (user, userDto, serverLanguages) ->
|
||||
if (user == null || userDto == null || serverLanguages.isEmpty() || user.id != userDto.id) {
|
||||
return@mapLatest PreferredLanguage()
|
||||
}
|
||||
val languages =
|
||||
serverLanguages.filter { it.threeLetterIsoLanguageName.isNotNullOrBlank() }
|
||||
|
||||
// Language user has chosen from local app
|
||||
val prefLang = user.appPreferences.let { if (isAudio) it.preferredAudioLanguage else it.preferredSubtitleLanguage }
|
||||
|
||||
// Language user has chosen from server user profile
|
||||
val userDisplayLang =
|
||||
userDto.configuration
|
||||
?.let { if (isAudio) it.audioLanguagePreference else it.subtitleLanguagePreference }
|
||||
.let { userLang ->
|
||||
languages.firstOrNull { it.threeLetterIsoLanguageName == userLang }?.displayName
|
||||
?: userLang
|
||||
}
|
||||
val selected =
|
||||
when (prefLang) {
|
||||
UserProfileSettings.USE_USER_PROFILE -> {
|
||||
PreferredLanguageType.ServerProfile(userDisplayLang)
|
||||
}
|
||||
|
||||
UserProfileSettings.PREFER_ANY_LANGUAGE -> {
|
||||
PreferredLanguageType.AnyLanguage
|
||||
}
|
||||
|
||||
else -> {
|
||||
languages
|
||||
.firstOrNull { it.threeLetterIsoLanguageName == prefLang }
|
||||
?.let {
|
||||
PreferredLanguageType.Language(
|
||||
it.threeLetterIsoLanguageName!!,
|
||||
it.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: PreferredLanguageType.ServerProfile(userDisplayLang)
|
||||
val options =
|
||||
buildList {
|
||||
add(PreferredLanguageType.ServerProfile(userDisplayLang))
|
||||
add(PreferredLanguageType.AnyLanguage)
|
||||
add(PreferredLanguageType.Divider)
|
||||
languages.forEach {
|
||||
if (it.threeLetterIsoLanguageName.isNotNullOrBlank()) {
|
||||
add(
|
||||
PreferredLanguageType.Language(
|
||||
it.threeLetterIsoLanguageName!!,
|
||||
it.displayName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PreferredLanguage(selected, options)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
PreferredLanguage(),
|
||||
)
|
||||
|
||||
val audioLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(true)
|
||||
val subtitleLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(false)
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverLanguages.value = api.localizationApi.getCultures().content
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePreferences(block: (JellyfinUserPreferences) -> JellyfinUserPreferences) {
|
||||
viewModelScope.launchIO {
|
||||
mutex.withLock {
|
||||
val user = currentUser.first { it != null }!!
|
||||
val newAppPrefs = block.invoke(user.appPreferences)
|
||||
serverDao.updateUser(user.copy(appPreferences = newAppPrefs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.user
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
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.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppChoicePreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.preferences.ChoicePreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.ClickPreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Page for showing settings derived from the user's profile on the server
|
||||
*
|
||||
* @see UserProfileSettings
|
||||
*/
|
||||
@Composable
|
||||
fun UserProfilePreferencesPage(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
UserProfilePreferencesContent(
|
||||
Modifier
|
||||
.fillMaxWidth(.4f)
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfilePreferencesContent(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: UserPreferencesViewModel = hiltViewModel(),
|
||||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
||||
val state = rememberLazyListState()
|
||||
|
||||
val userDto by viewModel.currentUserDto.collectAsState()
|
||||
val preferences by viewModel.userAppPreferences.collectAsState()
|
||||
val audioLanguagePref by viewModel.audioLanguage.collectAsState()
|
||||
val subtitleLanguagePref by viewModel.subtitleLanguage.collectAsState()
|
||||
var showPreferredLanguageDialog by remember { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
// Forces the animated to trigger
|
||||
visible = true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInHorizontally { it / 2 },
|
||||
exit = fadeOut() + slideOutHorizontally { it / 2 },
|
||||
modifier = modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.profile_specific_settings),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = state,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
UserProfileSettings.Preferences.forEachIndexed { groupIndex, group ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(group.title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
val groupPreferences =
|
||||
group.preferences +
|
||||
group.conditionalPreferences
|
||||
.filter { it.condition.invoke(preferences) }
|
||||
.map { it.preferences }
|
||||
.flatten()
|
||||
groupPreferences.forEachIndexed { prefIndex, pref ->
|
||||
pref as AppPreference<JellyfinUserPreferences, Any>
|
||||
item {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
|
||||
val modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
).onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = Pair(groupIndex, prefIndex)
|
||||
}
|
||||
|
||||
LaunchedEffect(focused) {
|
||||
if (focused) {
|
||||
focusedIndex = Pair(groupIndex, prefIndex)
|
||||
onFocus.invoke(groupIndex, prefIndex)
|
||||
}
|
||||
}
|
||||
when (pref) {
|
||||
UserProfileSettings.PreferredAudioLang -> {
|
||||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = { showPreferredLanguageDialog = true },
|
||||
summary = audioLanguagePref.selected.displayString.getString(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
UserProfileSettings.PreferredSubtitleLang -> {
|
||||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = { showPreferredLanguageDialog = false },
|
||||
summary = subtitleLanguagePref.selected.displayString.getString(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
UserProfileSettings.SubtitleModePref -> {
|
||||
pref as AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>
|
||||
val value = preferences.subtitleMode
|
||||
val values = stringArrayResource(pref.displayValues).toList()
|
||||
val summary =
|
||||
when (value) {
|
||||
SubtitleModePreference.USE_USER_PROFILE -> {
|
||||
val userMode = userDto?.configuration?.subtitleMode
|
||||
if (userMode != null) {
|
||||
"${values[0]} - ${stringResource(userMode.stringRes)}"
|
||||
} else {
|
||||
values[0]
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
values[value.ordinal]
|
||||
}
|
||||
}
|
||||
val selectedIndex =
|
||||
remember(value) { pref.valueToIndex.invoke(value) }
|
||||
ChoicePreference(
|
||||
title = stringResource(pref.title),
|
||||
summary = summary,
|
||||
possibleValues = values,
|
||||
selectedIndex = selectedIndex,
|
||||
onValueChange = { index ->
|
||||
viewModel.updatePreferences {
|
||||
it.copy(subtitleMode = SubtitleModePreference.entries[index])
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
subtitleDisplay = { index, _ ->
|
||||
val userMode = userDto?.configuration?.subtitleMode
|
||||
if (index == 0 && userMode != null) {
|
||||
{
|
||||
Text(stringResource(userMode.stringRes))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
ComposablePreference(
|
||||
preference = pref,
|
||||
value = value,
|
||||
onNavigate = viewModel.navigationManager::navigateTo,
|
||||
onValueChange = { newValue ->
|
||||
val validation = pref.validate(newValue)
|
||||
when (validation) {
|
||||
is PreferenceValidation.Invalid -> {
|
||||
// TODO?
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
validation.message,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
|
||||
PreferenceValidation.Valid -> {
|
||||
viewModel.updatePreferences {
|
||||
pref.setter(preferences, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
showPreferredLanguageDialog?.let { isAudio ->
|
||||
BasicDialog(
|
||||
onDismissRequest = { showPreferredLanguageDialog = null },
|
||||
elevation = 3.dp,
|
||||
) {
|
||||
val lang = if (isAudio) audioLanguagePref else subtitleLanguagePref
|
||||
FilterableLanguagePreference(
|
||||
title = if (isAudio) R.string.preferred_audio_language else R.string.preferred_subtitle_language,
|
||||
selectedOption = lang.selected,
|
||||
options = lang.options,
|
||||
onClickOption = { option ->
|
||||
val value =
|
||||
when (option) {
|
||||
PreferredLanguageType.AnyLanguage -> UserProfileSettings.PREFER_ANY_LANGUAGE
|
||||
is PreferredLanguageType.Language -> option.iso
|
||||
is PreferredLanguageType.ServerProfile -> UserProfileSettings.USE_USER_PROFILE
|
||||
PreferredLanguageType.Divider -> throw IllegalStateException("Cannot click on a divider")
|
||||
}
|
||||
Timber.v("Updating language pref to %s", option)
|
||||
viewModel.updatePreferences {
|
||||
if (isAudio) {
|
||||
it.copy(preferredAudioLanguage = value)
|
||||
} else {
|
||||
it.copy(preferredSubtitleLanguage = value)
|
||||
}
|
||||
}
|
||||
showPreferredLanguageDialog = null
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SubtitlePlaybackMode.stringRes: Int
|
||||
@StringRes get() =
|
||||
when (this) {
|
||||
SubtitlePlaybackMode.DEFAULT -> R.string.subtitle_mode_default
|
||||
SubtitlePlaybackMode.ALWAYS -> R.string.subtitle_mode_always
|
||||
SubtitlePlaybackMode.ONLY_FORCED -> R.string.subtitle_mode_only_forced
|
||||
SubtitlePlaybackMode.NONE -> R.string.subtitle_mode_none
|
||||
SubtitlePlaybackMode.SMART -> R.string.subtitle_mode_smart
|
||||
}
|
||||
|
|
@ -93,3 +93,13 @@ data class ResProviderStringProvider(
|
|||
@NonRestartableComposable
|
||||
override fun getString(): String = stringResource(stringResId, argProvider.getString())
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConcatStringProvider(
|
||||
val separator: String,
|
||||
val providers: List<StringProvider>,
|
||||
) : StringProvider {
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
override fun getString(): String = providers.map { it.getString() }.joinToString(separator)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@
|
|||
<string name="playlists">Playlists</string>
|
||||
<string name="preview">Preview</string>
|
||||
<string name="profile_specific_settings">User Profile Settings</string>
|
||||
<string name="profile_specific_settings_from_server">User profile settings from server</string>
|
||||
<string name="queue">Queue</string>
|
||||
<string name="recap">Recap</string>
|
||||
<string name="recently_added_in">Recently added in %1$s</string>
|
||||
|
|
@ -844,4 +845,27 @@
|
|||
<string name="video_tunneling">Video tunneling</string>
|
||||
<string name="blocklisted">Blocklisted</string>
|
||||
<string name="unavailable">Unavailable</string>
|
||||
<string name="subtitle_mode">Subtitle mode</string>
|
||||
|
||||
<string name="use_user_profile">Use your profile setting</string>
|
||||
|
||||
<string name="subtitle_mode_default">Default</string>
|
||||
<string name="subtitle_mode_smart">Smart</string>
|
||||
<string name="subtitle_mode_only_forced">Only Forced</string>
|
||||
<string name="subtitle_mode_always">Always</string>
|
||||
<string name="subtitle_mode_none">None</string>
|
||||
<string-array name="subtitle_mode_options">
|
||||
<item>@string/use_user_profile</item>
|
||||
<item>@string/subtitle_mode_default</item>
|
||||
<item>@string/subtitle_mode_smart</item>
|
||||
<item>@string/subtitle_mode_only_forced</item>
|
||||
<item>@string/subtitle_mode_always</item>
|
||||
<item>@string/subtitle_mode_none</item>
|
||||
</string-array>
|
||||
|
||||
<string name="preferred_subtitle_language">Preferred subtitle language</string>
|
||||
<string name="preferred_audio_language">Preferred audio language</string>
|
||||
<string name="override_user_profile_settings">More user profile settings</string>
|
||||
<string name="override_user_profile_settings_summary">Override your user profile settings from the server</string>
|
||||
<string name="any_language">Any language</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ package com.github.damontecres.wholphin.services
|
|||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -800,6 +803,80 @@ class TestStreamChoiceServiceOnlyForcedClientOverride(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED).
|
||||
* This tests the findForcedTrack function with user subtitle language preference.
|
||||
*/
|
||||
@RunWith(Parameterized::class)
|
||||
class TestStreamChoiceServiceAppPreferences(
|
||||
val input: TestInput,
|
||||
) {
|
||||
@Test
|
||||
fun test() {
|
||||
runTest(input)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{index}: {0}")
|
||||
fun data(): Collection<TestInput> =
|
||||
listOf(
|
||||
TestInput(
|
||||
expectedIndex = 1,
|
||||
userSubtitleMode = SubtitlePlaybackMode.ALWAYS,
|
||||
userSubtitleLang = "spa",
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
itemPlayback = null,
|
||||
appSubtitleLang = UserProfileSettings.USE_USER_PROFILE,
|
||||
),
|
||||
TestInput(
|
||||
expectedIndex = 0,
|
||||
userSubtitleMode = SubtitlePlaybackMode.ALWAYS,
|
||||
userSubtitleLang = "spa",
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
itemPlayback = null,
|
||||
appSubtitleLang = "eng",
|
||||
),
|
||||
TestInput(
|
||||
expectedIndex = 1,
|
||||
userSubtitleMode = SubtitlePlaybackMode.ALWAYS,
|
||||
userSubtitleLang = "spa",
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
itemPlayback = null,
|
||||
appSubtitleLang = "spa",
|
||||
),
|
||||
TestInput(
|
||||
expectedIndex = 1, // ItemPlayback
|
||||
userSubtitleMode = SubtitlePlaybackMode.ALWAYS,
|
||||
userSubtitleLang = "spa",
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
itemPlayback = itemPlayback(subtitleIndex = 1),
|
||||
appSubtitleLang = "spa",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class TestInput(
|
||||
val expectedIndex: Int?,
|
||||
val userSubtitleMode: SubtitlePlaybackMode?,
|
||||
|
|
@ -809,6 +886,9 @@ data class TestInput(
|
|||
val subtitles: List<MediaStream>,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val plc: PlaybackLanguageChoice? = null,
|
||||
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
|
||||
) {
|
||||
override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})"
|
||||
}
|
||||
|
|
@ -850,7 +930,15 @@ private fun runTest(input: TestInput) {
|
|||
candidates = input.subtitles,
|
||||
itemPlayback = input.itemPlayback,
|
||||
playbackLanguageChoice = input.plc,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance()),
|
||||
prefs =
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
JellyfinUserPreferences(
|
||||
preferredAudioLanguage = input.appAudioLang,
|
||||
preferredSubtitleLanguage = input.appSubtitleLang,
|
||||
subtitleMode = input.appSubtitleMode,
|
||||
),
|
||||
),
|
||||
)
|
||||
Assert.assertEquals(input.expectedIndex, result?.index)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ class PlaybackViewModelTests {
|
|||
|
||||
private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) {
|
||||
val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block)
|
||||
val prefs = UserPreferences(appPrefs)
|
||||
val prefs = UserPreferences(appPrefs, null)
|
||||
coEvery { mockUserPreferencesService.getCurrent() } returns prefs
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue