v1.1.0
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:
justinzeus 2026-11-05 01:39:09 +01:00
commit 670e95f2fa
159 changed files with 10810 additions and 2864 deletions

View file

@ -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'

View file

@ -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: |

View file

@ -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

View file

@ -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: |

View file

@ -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)

View file

@ -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 { *; }

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -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>

View 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>

View file

@ -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"

View file

@ -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

View file

@ -122,5 +122,7 @@ class WholphinApplication :
private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0)
}
}

View file

@ -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")
}
}
}
}

View file

@ -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 {

View file

@ -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,
)

View file

@ -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)
}
}

View file

@ -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>
}

View file

@ -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
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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
*/

View file

@ -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,
)
}

View file

@ -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]
*/

View file

@ -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,
)
}

View file

@ -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(

View file

@ -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 }
}

View file

@ -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)

View file

@ -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,
)

View file

@ -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,
}

View file

@ -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,

View file

@ -186,6 +186,8 @@ class AppPreferencesSerializer
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
preferDolbyVisionOverHdr10Plus =
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
convertDolbyVisionProfile7 =
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
}.build()
}.build()

View file

@ -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,
),

View file

@ -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) }

View file

@ -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)
}
}
}
}
}

View file

@ -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,
)

View file

@ -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)

View file

@ -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),
}

View file

@ -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

View file

@ -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")
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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) &&

View file

@ -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)
}

View file

@ -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) }
}
}

View file

@ -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()
}

View file

@ -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
}
}
}
}

View file

@ -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

View file

@ -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(

View file

@ -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()
}

View file

@ -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(),
)
}
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -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
}

View file

@ -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(

View file

@ -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) },
)
},

View file

@ -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,

View file

@ -97,6 +97,7 @@ class StudioViewModel
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
tag = it.imageTags?.get(ImageType.THUMB),
)
Studio(
id = it.id,

View file

@ -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 {

View file

@ -54,8 +54,8 @@ fun VideoStreamDetails(
) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
chosenStreams?.audioStream,
chosenStreams?.subtitleStream,
chosenStreams?.audioStream?.stream,
chosenStreams?.subtitleStream?.stream,
numberOfVersions,
modifier,
)

View file

@ -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

View file

@ -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)"
}

View file

@ -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) {

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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,

View file

@ -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,
)

View file

@ -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 =

View file

@ -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),

View file

@ -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) }
}
}

View file

@ -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) {

View file

@ -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) }
}
}

View file

@ -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) }
}
}

View file

@ -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,

View file

@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
@Serializable
data object GlobalSettings : HomeSettingsDestination
@Serializable
data object SourceSettings : HomeSettingsDestination
@Serializable
data object Presets : HomeSettingsDestination
}

View file

@ -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,
)
}
}
}
}

View file

@ -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(

View file

@ -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 {

View file

@ -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,
)
}
}
}
}

View file

@ -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(),

View file

@ -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,
)
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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())
}

View file

@ -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,
)
}
}
}

View file

@ -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 =

View file

@ -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

View file

@ -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>()

View file

@ -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]),
)
}
}

View file

@ -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 }
}

View file

@ -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
}

View file

@ -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 =

View file

@ -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(

View file

@ -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),
)
}
}

View file

@ -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,
}

View file

@ -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