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: steps:
# Setup the SDKs # Setup the SDKs
- name: Set up Python - name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with: with:
python-version: '3.14' python-version: '3.14'
- name: Setup JDK - name: Setup JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6
with: with:
distribution: zulu distribution: zulu
java-version: '21' java-version: '21'

View file

@ -77,7 +77,7 @@ jobs:
echo "SHA256 checksums:" echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files - name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with: with:
name: mapping name: mapping
path: | path: |

View file

@ -20,7 +20,7 @@ jobs:
with: with:
fetch-depth: 0 # Need the tags to build fetch-depth: 0 # Need the tags to build
- name: Setup Python - name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with: with:
python-version: '3.14' python-version: '3.14'
- name: Run pre-commit - name: Run pre-commit

View file

@ -59,14 +59,14 @@ jobs:
echo "SHA256 checksums:" echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB - name: Upload AAB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with: with:
name: AAB name: AAB
path: | path: |
app/build/outputs/bundle/**/*.aab app/build/outputs/bundle/**/*.aab
compression-level: 0 compression-level: 0
- name: Upload mapping files - name: Upload mapping files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with: with:
name: mapping name: mapping
path: | path: |

View file

@ -315,6 +315,7 @@ dependencies {
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media) implementation(libs.ass.media)
implementation(libs.exoplayerhdrutils)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -22,3 +22,6 @@
-keep class androidx.media3.ui.SubtitlePainter { -keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth; private final float outlineWidth;
} }
# Keep external/native functions
-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }

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"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
<uses-permission
android:name="com.amazon.permission.media.session.voicecommandcontrol"
tools:node="remove" />
</manifest> </manifest>

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:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-permission android:name="com.amazon.permission.media.session.voicecommandcontrol" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"

View file

@ -9,6 +9,7 @@ import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -77,7 +78,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -86,7 +86,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -444,120 +443,27 @@ class MainActivityViewModel
viewModelScope.launchDefault { viewModelScope.launchDefault {
mutex.withLock { mutex.withLock {
try { try {
val result = intent?.let { intentService.parseIntent(intent) } handleAppUpgrade()
when (result) { } catch (ex: Exception) {
is IntentResult.Error -> { Timber.e(ex, "Error during app upgrade!")
val current = serverRepository.current.value showToast(
val destination = context,
if (current != null) { "Error during app upgrade: ${ex.localizedMessage}",
SetupDestination.UserList(current.server) )
} else { }
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return@withLock
}
is IntentResult.Target -> { try {
val current = serverRepository.current.value val result = handleIntent(intent)
if (current != null) { if (result) {
Timber.i("Received valid intent, switching to AppContent") Timber.i("appStart was handled by intent")
return@withLock
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return@withLock
}
IntentResult.NoOp,
null,
-> {
// No-op, proceed below
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error parsing intent") Timber.e(ex, "Error parsing intent")
} }
try { try {
val needUpgrade = appUpgradeHandler.needUpgrade() handleAppStart()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
if (prefs.signInAutomatically && !profileProtected) {
val current =
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
)
if (current != null) {
if (current.user.isProtected) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
current.server,
),
)
} else {
// Restored
setupNavigationManager.navigateTo(
SetupDestination.AppContent(
current,
),
)
}
} else {
// Did not restore
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
currentServer,
),
)
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during appStart") Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList) setupNavigationManager.navigateTo(SetupDestination.ServerList)
@ -582,6 +488,103 @@ class MainActivityViewModel
} }
} }
} }
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart() {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
// Hot reload
Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser),
)
} else {
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
serverRepository.restoreLastSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
}
}
} }
private val Destination?.isPlayback: Boolean private val Destination?.isPlayback: Boolean

View file

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

View file

@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -75,8 +74,10 @@ class WholphinDreamService :
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault { lifecycleScope.launchDefault {
if (serverRepository.current.value == null) { if (serverRepository.current.value == null) {
val prefs = preferencesDataStore.data.first() val result = serverRepository.restoreLastSession()
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) if (result !is RestoredSession.Success) {
Timber.w("Could not restore user session")
}
} }
} }
} }

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.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
@ -48,8 +49,9 @@ import java.util.UUID
SeerrServer::class, SeerrServer::class,
SeerrUser::class, SeerrUser::class,
RememberedTab::class, RememberedTab::class,
SeriesTrackChoice::class,
], ],
version = 35, version = 41,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -68,6 +70,8 @@ import java.util.UUID
AutoMigration(32, 33), AutoMigration(32, 33),
AutoMigration(33, 34), AutoMigration(33, 34),
AutoMigration(34, 35), AutoMigration(34, 35),
AutoMigration(35, 40),
AutoMigration(40, 41),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -87,6 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao abstract fun rememberedTabDao(): RememberedTabDao
abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
} }
class Converters { class Converters {

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.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.RelativeMediaStream
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -28,7 +31,7 @@ class ItemPlaybackRepository
constructor( constructor(
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
) { ) {
/** /**
@ -45,54 +48,79 @@ class ItemPlaybackRepository
): ChosenStreams? = ): ChosenStreams? =
serverRepository.currentUser?.let { user -> serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) return getChosenItemFromPlayback(item, itemPlayback, prefs)
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
} }
suspend fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
prefs: UserPreferences,
): ChosenStreams? {
val audioStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.AUDIO,
)
val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.SUBTITLE,
)
return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs)
}
/** /**
* Get the chosen source & stream for the given item * Get the chosen source & stream for the given item
* *
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
*/ */
fun getChosenItemFromPlayback( fun getChosenItemFromPlayback(
item: BaseItem, item: BaseItem,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?, audioStc: List<SeriesTrackChoice>,
subtitleStc: List<SeriesTrackChoice>,
prefs: UserPreferences, prefs: UserPreferences,
): ChosenStreams? { ): ChosenStreams? {
Timber.v(
"For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
item.id,
itemPlayback != null,
audioStc.isNotEmpty(),
subtitleStc.isNotEmpty(),
)
val source = val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) { if (source != null) {
val audioStream = val audioResult =
streamChoiceService.chooseAudioStream( streamChoiceService.chooseAudioStream(
candidates = candidates =
source.mediaStreams source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO } ?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(), .orEmpty(),
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
playbackLanguageChoice = plc, stc = audioStc,
prefs = prefs, prefs = prefs,
) )
val subtitleStream = val subtitleStream =
streamChoiceService.chooseSubtitleStream( streamChoiceService.chooseSubtitleStream(
audioStreamLang = audioStream?.language, audioStreamLang = audioResult.stream?.language,
candidates = candidates =
source.mediaStreams source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(), .orEmpty(),
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
playbackLanguageChoice = plc, stc = subtitleStc,
prefs = prefs, prefs = prefs,
) )
return ChosenStreams( return ChosenStreams(
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
plc = plc, audioStc = audioStc,
subtitleStc = subtitleStc,
itemId = item.id, itemId = item.id,
source = source, source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
audioStream = audioStream, audioStream = audioResult,
subtitleStream = subtitleStream, subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
) )
@ -156,26 +184,36 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave) Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave) toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId val seriesId = item.data.seriesId
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { if (seriesId != null || item.data.parentId != null) {
if (type == MediaStreamType.AUDIO) { if (type == MediaStreamType.AUDIO) {
val stream = source.mediaStreams?.first { it.index == trackIndex } val stream =
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) { if (stream?.language != null) {
streamChoiceService.updateAudio(item.data, stream.language!!) streamChoiceService.saveSeriesTrackChoice(
item.data,
stream.type,
stream,
)
} }
} else if (type == MediaStreamType.SUBTITLE) { } else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) { if (trackIndex == TrackIndex.DISABLED) {
streamChoiceService.updateSubtitles( streamChoiceService.saveDisabledSeriesTrackChoice(
item.data, item.data,
subtitleLang = null, MediaStreamType.SUBTITLE,
subtitlesDisabled = true, )
} else if (trackIndex == TrackIndex.ONLY_FORCED) {
streamChoiceService.saveOnlyForcedSeriesTrackChoice(
item.data,
MediaStreamType.SUBTITLE,
) )
} else { } else {
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } val stream =
RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) { if (stream?.language != null) {
streamChoiceService.updateSubtitles( streamChoiceService.saveSeriesTrackChoice(
item.data, item.data,
stream.language!!, stream.type,
subtitlesDisabled = false, stream,
) )
} }
} }
@ -240,9 +278,10 @@ class ItemPlaybackRepository
*/ */
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams) Timber.d("deleteChosenStreams: %s", chosenStreams)
chosenStreams?.plc?.let { val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
Timber.d("Deleting %s", it) if (stc.isNotEmpty()) {
playbackLanguageChoiceDao.delete(it) Timber.d("Deleting %s", stc)
seriesTrackChoiceDao.delete(stc)
} }
chosenStreams?.itemPlayback?.let { chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it) Timber.d("Deleting %s", it)
@ -256,11 +295,12 @@ class ItemPlaybackRepository
*/ */
data class ChosenStreams( data class ChosenStreams(
val itemPlayback: ItemPlayback?, val itemPlayback: ItemPlayback?,
val plc: PlaybackLanguageChoice?, val audioStc: List<SeriesTrackChoice>,
val subtitleStc: List<SeriesTrackChoice>,
val itemId: UUID, val itemId: UUID,
val source: MediaSourceInfo, val source: MediaSourceInfo,
val videoStream: MediaStream?, val videoStream: MediaStream?,
val audioStream: MediaStream?, val audioStream: StreamChoiceResult,
val subtitleStream: MediaStream?, val subtitleStream: StreamChoiceResult,
val subtitlesDisabled: Boolean, val subtitlesDisabled: Boolean,
) )

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 com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID import java.util.UUID
@Deprecated("Use SeriesTrackChoiceDao")
@Dao @Dao
interface PlaybackLanguageChoiceDao { interface PlaybackLanguageChoiceDao {
@Deprecated("Use SeriesTrackChoiceDao")
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get( suspend fun get(
userId: Int, userId: Int,
seriesId: UUID, seriesId: UUID,
): PlaybackLanguageChoice? ): PlaybackLanguageChoice?
@Deprecated("Use SeriesTrackChoiceDao")
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long suspend fun save(plc: PlaybackLanguageChoice): Long
@Deprecated("Use SeriesTrackChoiceDao")
@Delete @Delete
fun delete(plc: PlaybackLanguageChoice) fun delete(plc: PlaybackLanguageChoice)
@Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
suspend fun getAll(
limit: Int,
offset: Int,
): List<PlaybackLanguageChoice>
} }

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 package com.github.damontecres.wholphin.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -49,8 +44,8 @@ class ServerRepository
val jellyfin: Jellyfin, val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val mostRecentServerProvider: MostRecentServerProvider,
) { ) {
private var _current = MutableStateFlow<CurrentUser?>(null) private var _current = MutableStateFlow<CurrentUser?>(null)
val current: StateFlow<CurrentUser?> = _current val current: StateFlow<CurrentUser?> = _current
@ -77,6 +72,8 @@ class ServerRepository
} }
} }
val serverPluginInstalled = MutableStateFlow<Boolean>(false)
/** /**
* Adds a server to the app database and updated the [ApiClient] to the server's URL * Adds a server to the app database and updated the [ApiClient] to the server's URL
* *
@ -88,10 +85,11 @@ class ServerRepository
} }
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
_current.value = null _current.value = null
mostRecentServerProvider.clearUser()
} }
/** /**
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user * Saves the server & user to the app database and updates the [ApiClient] to use this server & user
*/ */
suspend fun changeUser( suspend fun changeUser(
server: JellyfinServer, server: JellyfinServer,
@ -119,70 +117,101 @@ class ServerRepository
) )
serverDao.addOrUpdateServer(updatedServer) serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser) updatedUser = serverDao.addOrUpdateUser(updatedUser)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = updatedServer.id.toServerString()
currentUserId = updatedUser.id.toServerString()
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser) val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(WholphinDispatchers.Main) { mostRecentServerProvider.save(currentUser)
_current.value = currentUser _current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto) _currentUserDto.value = ServerUserConfig(userDto)
}
getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
return@withContext currentUser return@withContext currentUser
} }
/** /**
* Restores a session for the given server & user such as when the app reopens * Try to restore the most recent session
* *
* If user has a PIN, this returns false * Does not check if auto sign-in is enabled, but does check if the user profile is protected
*/ */
suspend fun restoreSession( suspend fun restoreLastSession(): RestoredSession {
val recentServer = mostRecentServerProvider.get()
Timber.d("restoreLastSession: recentServer=%s", recentServer)
val result =
when (recentServer) {
MostRecentServer.None -> {
RestoredSession.None
}
is MostRecentServer.Server -> {
val server = serverDao.getServer(recentServer.serverId)
if (server != null) {
RestoredSession.ServerOnly(server.server)
} else {
RestoredSession.None
}
}
is MostRecentServer.ServerAndUser -> {
val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
if (currentUser != null) {
if (currentUser.user.isProtected) {
RestoredSession.ServerOnly(currentUser.server)
} else {
RestoredSession.Success(currentUser)
}
} else {
getMostRecentServerInternal(recentServer)
}
}
}
return result
}
/**
* Get the most recently used server, if any
*/
suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
val recentServer = recentServer ?: mostRecentServerProvider.get()
val serverId =
when (recentServer) {
MostRecentServer.None -> null
is MostRecentServer.Server -> recentServer.serverId
is MostRecentServer.ServerAndUser -> recentServer.serverId
}
val server = serverId?.let { serverDao.getServer(serverId)?.server }
return if (server != null) {
RestoredSession.ServerOnly(server)
} else {
RestoredSession.None
}
}
/**
* Try to change to the given server & user. If the user's profile is protected, returns null
*
* @return the server/user or null if the session could not be restored
*/
suspend fun tryChangeUser(
serverId: UUID?, serverId: UUID?,
userId: UUID?, userId: UUID?,
): CurrentUser? { ): CurrentUser? =
if (serverId == null || userId == null) { withContext(ioDispatcher) {
_current.value = null return@withContext if (serverId == null || userId == null) {
return null _current.value = null
} null
val serverAndUsers =
withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
val current = _current.value
if (current != null && current.server.id == serverId && current.user.id == userId) {
Timber.v("Restoring session for current user, so shortcut")
apiClient.update(
baseUrl = current.server.url,
accessToken = current.user.accessToken,
)
return current
} else { } else {
val user = serverAndUsers.users.firstOrNull { it.id == userId } val serverAndUsers = serverDao.getServer(serverId)
if (user != null) { if (serverAndUsers != null) {
return changeUser(serverAndUsers.server, user) val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null && !user.isProtected) {
changeUser(serverAndUsers.server, user)
} else {
null
}
} else {
null
} }
} }
} }
return null
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
fun closeSession() {
_current.value = null
}
/** /**
* Given a successful [AuthenticationResult], switch to the user that just authenticated * Given a successful [AuthenticationResult], switch to the user that just authenticated
@ -244,13 +273,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
_current.value = null _current.value = null
} }
userPreferencesDataStore.updateData { mostRecentServerProvider.clearUser()
it
.toBuilder()
.apply {
currentUserId = ""
}.build()
}
apiClient.update(accessToken = null) apiClient.update(accessToken = null)
} }
withContext(ioDispatcher) { withContext(ioDispatcher) {
@ -263,14 +286,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
_current.value = null _current.value = null
} }
userPreferencesDataStore.updateData { mostRecentServerProvider.clear()
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
apiClient.update(baseUrl = null, accessToken = null) apiClient.update(baseUrl = null, accessToken = null)
} }
withContext(ioDispatcher) { withContext(ioDispatcher) {
@ -279,14 +295,7 @@ class ServerRepository
} }
suspend fun switchServerOrUser() { suspend fun switchServerOrUser() {
userPreferencesDataStore.updateData { mostRecentServerProvider.clear()
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
} }
suspend fun updateUserAuth( suspend fun updateUserAuth(
@ -335,17 +344,6 @@ class ServerRepository
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
} }
} }
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
"${context.packageName}_server",
Context.MODE_PRIVATE,
)
const val SERVER_URL_KEY = "current.server"
const val ACCESS_TOKEN_KEY = "current.accessToken"
}
} }
@Serializable @Serializable

View file

@ -66,6 +66,7 @@ data class BaseItem(
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ")
else -> data.productionYear?.toString() else -> data.productionYear?.toString()
} }
} }

View file

@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType import org.jellyfin.sdk.model.api.VideoType
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@ -155,6 +156,22 @@ data class GetItemsFilter(
}, },
) )
fun applyTo(req: GetAlbumArtistsRequest) =
req.copy(
minCommunityRating = minCommunityRating,
isFavorite = favorite,
genreIds = genres,
personIds = persons,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
)
/** /**
* Merge another [GetItemsFilter] onto this one, replacing only unset values * Merge another [GetItemsFilter] onto this one, replacing only unset values
*/ */

View file

@ -1,4 +1,7 @@
@file:UseSerializers(UUIDSerializer::class) @file:UseSerializers(
UUIDSerializer::class,
DateTimeSerializer::class,
)
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.DateTimeSerializer
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
@ -174,19 +181,45 @@ sealed interface HomeRowConfig {
} }
/** /**
* An arbitrary [GetItemsRequest] allowing to query for anything * An arbitrary [HomeGetItemsFilter] allowing to query for most anything
*/ */
@Serializable @Serializable
@SerialName("GetItems") @SerialName("GetItems")
data class GetItems( data class GetItems(
val name: String, val name: String,
val getItems: GetItemsRequest, val filter: HomeGetItemsFilter,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig { ) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
} }
/**
* Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>.
* Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing
* specific knowledge of them.
*
* Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin
* config is persisted as XML, which doesn't support IDictionary.
*/
@Serializable
@SerialName("CustomEndpoint")
data class CustomEndpoint(
val endpoint: String,
val title: String,
val headers: List<KeyValueEntry>? = null,
val query: List<KeyValueEntry>? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions)
}
} }
@Serializable
data class KeyValueEntry(
val key: String,
val value: String,
)
/** /**
* Root class for home page settings * Root class for home page settings
* *
@ -241,3 +274,54 @@ data class HomeRowViewOptions(
) )
} }
} }
/**
* Subset of [GetItemsFilter]
*/
@Serializable
data class HomeGetItemsFilter(
val sortBy: List<ItemSortBy>? = null,
val sortOrder: List<SortOrder>? = null,
val ids: List<UUID>? = null,
val minCommunityRating: Double? = null,
val minCriticRating: Double? = null,
val recursive: Boolean? = null,
val searchTerm: String? = null,
val parentId: UUID? = null,
val includeItemTypes: List<BaseItemKind>? = null,
val isFavorite: Boolean? = null,
val isPlayed: Boolean? = null,
val officialRatings: Collection<String>? = null,
val tags: List<String>? = null,
val years: List<Int>? = null,
val personIds: List<UUID>? = null,
val minOfficialRating: String? = null,
val hasOfficialRating: Boolean? = null,
val studioIds: List<UUID>? = null,
val genreIds: List<UUID>? = null,
val minPremiereDate: LocalDateTime? = null,
val maxPremiereDate: LocalDateTime? = null,
) {
fun asGetItemsRequest() =
GetItemsRequest(
sortBy = sortBy,
sortOrder = sortOrder,
ids = ids,
minCommunityRating = minCommunityRating,
minCriticRating = minCriticRating,
recursive = recursive,
searchTerm = searchTerm,
parentId = parentId,
includeItemTypes = includeItemTypes,
isFavorite = isFavorite,
isPlayed = isPlayed,
officialRatings = officialRatings,
tags = tags,
years = years,
personIds = personIds,
minOfficialRating = minOfficialRating,
hasOfficialRating = hasOfficialRating,
studioIds = studioIds,
genreIds = genreIds,
)
}

View file

@ -13,6 +13,7 @@ import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
@ -68,6 +69,7 @@ data class JellyfinUser(
val lastUsed: ZonedDateTime? = null, val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null, val uiLanguage: String? = null,
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
@Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
) { ) {
val hasPin: Boolean get() = pin.isNotNullOrBlank() val hasPin: Boolean get() = pin.isNotNullOrBlank()
@ -77,9 +79,17 @@ data class JellyfinUser(
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
"appPreferences=$appPreferences" "appPreferences=$appPreferences, config=$config"
fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
} }
@Serializable
data class JellyfinUserConfig(
@ColumnInfo(defaultValue = "UNSET")
val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
)
/** /**
* Represents the relationship between [JellyfinServer] and its [JellyfinUser] * Represents the relationship between [JellyfinServer] and its [JellyfinUser]
*/ */

View file

@ -35,7 +35,7 @@ data class Person(
name = dto.name, name = dto.name,
role = personRole(context, dto.role, dto.type), role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = false, favorite = false,
) )
@ -50,7 +50,7 @@ data class Person(
name = dto.name, name = dto.name,
role = personRole(context, dto.role, dto.type), role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = favorite, favorite = favorite,
) )
} }

View file

@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
/** /**
* DEPRECATED
*
* Stores the language choices for a series so they can be applied automatically to other episodes * Stores the language choices for a series so they can be applied automatically to other episodes
* without the user needing to explicitly choose the tracks * without the user needing to explicitly choose the tracks
*
* @see SeriesTrackChoice
*/ */
@Deprecated("Use SeriesTrackChoice")
@Entity( @Entity(
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(

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) } || permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions) SeerrPermission.ADMIN.hasPermission(permissions)
} }
val SeerrUserConfig?.canRequestMovies: Boolean
get() =
hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
val SeerrUserConfig?.canRequestTv: Boolean
get() =
hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV)

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.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
@ -345,7 +346,7 @@ sealed interface AppPreference<Pref, T> {
const val MEGA_BIT = 1024 * 1024L const val MEGA_BIT = 1024 * 1024L
const val DEFAULT_BITRATE = 100 * MEGA_BIT const val DEFAULT_BITRATE = 100 * MEGA_BIT
private val bitrateValues = val bitrateValues =
listOf( listOf(
500 * 1024L, 500 * 1024L,
750 * 1024L, 750 * 1024L,
@ -378,11 +379,7 @@ sealed interface AppPreference<Pref, T> {
summarizer = { value -> summarizer = { value ->
if (value != null) { if (value != null) {
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
if (v < MEGA_BIT) { formatMaxBitrate(v)
"${v / 1024} kbps"
} else {
"${v / MEGA_BIT} Mbps"
}
} else { } else {
null null
} }
@ -456,15 +453,69 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DirectPlayDoviProfile7 = val Hdr10Override =
AppSwitchPreference<AppPreferences>( AppChoicePreference<AppPreferences, HdrOverrideMode>(
title = R.string.force_dovi_profile_7, title = R.string.hdr10_override,
defaultValue = false, defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, getter = { it.playbackPreferences.overrides.hdr10Override },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } prefs.updatePlaybackOverrides { hdr10Override = value }
}, },
summary = R.string.force_dovi_profile_7_summary, displayValues = R.array.hdr_override_modes,
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
)
val Hdr10PlusOverride =
AppChoicePreference<AppPreferences, HdrOverrideMode>(
title = R.string.hdr10_plus_override,
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
getter = { it.playbackPreferences.overrides.hdr10PlusOverride },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { hdr10PlusOverride = value }
},
displayValues = R.array.hdr_override_modes,
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
)
val DoviProfile5Override =
AppChoicePreference<AppPreferences, HdrOverrideMode>(
title = R.string.dovi_profile_5,
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
getter = { it.playbackPreferences.overrides.doviProfile5Override },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { doviProfile5Override = value }
},
displayValues = R.array.hdr_override_modes,
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
)
val DoviProfile7Override =
AppChoicePreference<AppPreferences, HdrOverrideMode>(
title = R.string.dovi_profile_7,
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
getter = { it.playbackPreferences.overrides.doviProfile7Override },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { doviProfile7Override = value }
},
displayValues = R.array.hdr_override_modes,
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
)
val DoviProfile8Override =
AppChoicePreference<AppPreferences, HdrOverrideMode>(
title = R.string.dovi_profile_8,
defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
getter = { it.playbackPreferences.overrides.doviProfile8Override },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { doviProfile8Override = value }
},
displayValues = R.array.hdr_override_modes,
indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
) )
val DecodeAv1 = val DecodeAv1 =
@ -919,6 +970,12 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.MPV), destination = Destination.Settings(PreferenceScreenOption.MPV),
) )
val HdrFormatSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.hdr_formats,
destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
)
val MpvHardwareDecoding = val MpvHardwareDecoding =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding, title = R.string.mpv_hardware_decoding,
@ -1183,8 +1240,17 @@ private val ExoPlayerSettings =
AppPreference.Ac3Supported, AppPreference.Ac3Supported,
AppPreference.AssSubtitleMode, AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs, AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1, AppPreference.DecodeAv1,
AppPreference.HdrFormatSettings,
)
private val HdrFormatSettings =
listOf(
AppPreference.Hdr10Override,
AppPreference.Hdr10PlusOverride,
AppPreference.DoviProfile5Override,
AppPreference.DoviProfile7Override,
AppPreference.DoviProfile8Override,
) )
val ExoPlayerPreferences = val ExoPlayerPreferences =
@ -1195,6 +1261,14 @@ val ExoPlayerPreferences =
), ),
) )
val HdrFormatPreferences =
listOf(
PreferenceGroup(
title = R.string.hdr_formats,
preferences = HdrFormatSettings,
),
)
private val MpvSettings = private val MpvSettings =
listOf( listOf(
AppPreference.MpvHardwareDecoding, AppPreference.MpvHardwareDecoding,

View file

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

View file

@ -48,6 +48,17 @@ object ExperimentalPreference {
summary = R.string.prefer_dovi_over_hdr10_plus_summary, summary = R.string.prefer_dovi_over_hdr10_plus_summary,
) )
val ConvertDolbyVisionProfile7 =
AppSwitchPreference<AppPreferences>(
title = R.string.convert_dovi_profile_7,
defaultValue = false,
getter = { it.experimentalPreferences.convertDolbyVisionProfile7 },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value }
},
summary = R.string.convert_dovi_profile_7_summary,
)
val PreferAc3ForSurround = val PreferAc3ForSurround =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.prefer_ac3_for_surround, title = R.string.prefer_ac3_for_surround,
@ -93,6 +104,7 @@ val experimentalPreferences =
listOf( listOf(
ExperimentalPreference.VideoTunneling, ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
ExperimentalPreference.ConvertDolbyVisionProfile7,
ExperimentalPreference.PreferAc3ForSurround, ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload, ExperimentalPreference.DisableAudioOffload,
), ),

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.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference import com.github.damontecres.wholphin.preferences.ExperimentalPreference
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
@ -36,6 +42,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
@ -56,6 +63,9 @@ class AppUpgradeHandler
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao, private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao, private val rememberedTabDao: RememberedTabDao,
private val mostRecentServerProvider: MostRecentServerProvider,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) { ) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -442,5 +452,59 @@ class AppUpgradeHandler
} }
} }
} }
if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
try {
val prefs = appPreferences.data.firstOrNull()
val serverId = prefs?.currentServerId?.toUUIDOrNull()
val userId = prefs?.currentUserId?.toUUIDOrNull()
if (serverId != null && userId != null) {
val server = serverDao.getServer(serverId)
val user = server?.users?.firstOrNull { it.id == userId }
if (server != null && user != null) {
Timber.i("Migrating most recent server")
mostRecentServerProvider.save(CurrentUser(server.server, user))
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error during migration for most recent server")
}
appPreferences.updateData {
it.update {
currentServerId = ""
currentUserId = ""
}
}
}
if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) {
// directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward
appPreferences.updateData {
if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) {
it.updatePlaybackOverrides {
doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
directPlayDolbyVisionEL = false
}
} else {
it
}
}
}
if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
val count = seriesTrackChoiceDao.count()
if (count == 0) {
var offset = 0
while (true) {
val plcs = playbackLanguageChoiceDao.getAll(100, offset)
if (plcs.isEmpty()) break
offset += plcs.size
val stcs = plcs.flatMap(SeriesTrackChoice::from)
Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
seriesTrackChoiceDao.save(stcs)
}
}
}
} }
} }

View file

@ -4,8 +4,12 @@ import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
import com.github.damontecres.wholphin.preferences.HdrFormat
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -61,9 +65,22 @@ class DeviceProfileService
downMixAudio = newConfig.overrides.downmixStereo, downMixAudio = newConfig.overrides.downmixStereo,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs, pgsDirectPlay = newConfig.overrides.directPlayPgs,
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
forceEnabledHdr =
buildSet {
appPrefs
.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
.let(::addAll)
// A profile 7 track the player converts to 8.1 is one the server need not transcode
if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
HdrFormat.DOVI_PROFILE_7
.rangeTypeNames(newConfig.jellyfinTenEleven)
.let(::addAll)
}
},
forceDisabledHdr =
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
jellyfinTenEleven = newConfig.jellyfinTenEleven, jellyfinTenEleven = newConfig.jellyfinTenEleven,
maxResolution = newConfig.overrides.maxResolution, maxResolution = newConfig.overrides.maxResolution,
) )

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 android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -55,32 +34,15 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Handles getting home page settings and data * Handles getting home page settings and data
@ -92,21 +54,11 @@ class HomeSettingsService
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService, private val navDrawerService: NavDrawerService,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService, private val displayPreferencesService: DisplayPreferencesService,
private val serverPluginApi: ServerPluginApi,
private val serverDao: JellyfinServerDao,
) { ) {
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowTrailingComma = true
}
/** /**
* The current home page settings * The current home page settings
*/ */
@ -188,9 +140,8 @@ class HomeSettingsService
/** /**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
*
* This is public only for testing
*/ */
@VisibleForTesting
fun decode(element: JsonElement): HomePageSettings { fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
@ -211,6 +162,42 @@ class HomeSettingsService
return HomePageSettings(rows, version) return HomePageSettings(rows, version)
} }
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
try {
block.invoke()
} catch (ex: Exception) {
Timber.w(ex, "Error loading settings")
null
}
suspend fun fetchSettingsFor(
userId: UUID,
source: HomePageSettingsSource,
): HomePageResolvedSettings? {
val (resolvedSource, settings) =
when (source) {
HomePageSettingsSource.UNSET -> loadInOrder(userId)
HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId)
HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId)
HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings()
HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings()
HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings()
}
Timber.i(
"fetch home settings: resolvedSource=%s, userId=%s, found=%s",
resolvedSource,
userId,
settings != null,
)
return settings?.let {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
}
}
/** /**
* Loads [HomePageSettings] into [currentSettings] * Loads [HomePageSettings] into [currentSettings]
* *
@ -218,55 +205,70 @@ class HomeSettingsService
* *
* Does not persist either the server nor default * Does not persist either the server nor default
*/ */
suspend fun loadCurrentSettings(userId: UUID) { suspend fun loadCurrentSettings(user: JellyfinUser) {
Timber.v("Getting setting for %s", userId) currentSettings.update { HomePageResolvedSettings.EMPTY }
// User local then server/remote otherwise create a default
Timber.v("Getting setting for %s", user.id)
val settings = val settings =
try { try {
val local = loadFromLocal(userId) fetchSettingsFor(user.id, user.config.homeSettingsSource)
Timber.v("Found local? %s", local != null) } catch (ex: CancellationException) {
local throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error loading local settings") Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
// TODO show toast?
null
} ?: try {
val remote = loadFromServer(userId)
Timber.v("Found remote? %s", remote != null)
remote
} catch (ex: Exception) {
Timber.w(ex, "Error loading remote settings")
null null
} }
val resolvedSettings = Timber.v("Found settings: %s", settings != null)
if (settings != null) { val resolvedSettings = settings ?: createDefault(user.id)
Timber.v("Found settings")
// Resolve
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(userId, resolvedRows)
} else {
createDefault(userId)
}
currentSettings.update { resolvedSettings } currentSettings.update { resolvedSettings }
} }
/**
* Tries to load settings from local->server->plugin
*/
private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> {
Timber.v("loadInOrder: local")
var settings =
HomePageSettingsSource.LOCAL to
tryLoad {
loadFromLocal(userId)
}
if (settings.second == null) {
Timber.v("loadInOrder: server")
settings =
HomePageSettingsSource.SERVER_PROFILE to
tryLoad {
loadFromServer(userId)
}
}
if (settings.second == null && serverRepository.serverPluginInstalled.value) {
Timber.v("loadInOrder: plugin")
settings = HomePageSettingsSource.PLUGIN to
tryLoad {
serverPluginApi.fetchHomePageSettings()
}
}
return settings
}
/** /**
* Resolve the settings and set them to be the current settings * Resolve the settings and set them to be the current settings
*/ */
suspend fun updateCurrent( suspend fun updateCurrent(
userId: UUID, source: HomePageSettingsSource,
settings: HomePageSettings, settings: HomePageSettings,
) { ) {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> settings.rows.mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) currentSettings.update { it.copy(rows = resolvedRows) }
currentSettings.update { resolvedSettings } serverRepository.currentUser?.let { user ->
val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
serverDao.updateUser(toSave)
}
} }
/** /**
@ -316,7 +318,7 @@ class HomeSettingsService
), ),
) )
val rowConfig = continueWatchingRow + includedIds val rowConfig = continueWatchingRow + includedIds
return HomePageResolvedSettings(userId, rowConfig) return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
} }
/** /**
@ -432,7 +434,7 @@ class HomeSettingsService
null null
} }
}.flatten() }.flatten()
HomePageResolvedSettings(userId, rowConfigs) HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
} else { } else {
null null
} }
@ -560,6 +562,14 @@ class HomeSettingsService
config, config,
) )
} }
is HomeRowConfig.CustomEndpoint -> {
HomeRowConfigDisplay(
id = id,
title = StringStringProvider(config.title),
config,
)
}
} }
private suspend fun getItemName( private suspend fun getItemName(
@ -585,631 +595,16 @@ class HomeSettingsService
ResStringProvider(R.string.unknown) ResStringProvider(R.string.unknown)
} }
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
suspend fun fetchDataForRow(
row: HomeRowConfig,
scope: CoroutineScope,
prefs: HomePagePreferences,
userDto: ServerUserConfig,
libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow,
isRefresh: Boolean,
usePaging: Boolean = false,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
val combined = latestNextUpService.buildCombined(resume, nextUp)
Success(
title = ResStringProvider(R.string.continue_watching),
items = combined.take(limit),
viewOptions = row.viewOptions,
rowType = row,
showViewMore = combined.size >= limit,
)
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser?.id,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
useCache = isRefresh,
)
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
?: ResStringProvider(R.string.genres)
val genres =
items.map {
BaseItem(
it,
false,
genreImages[it.id],
createGenreDestination(
genreId = it.id,
genreName = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes =
library?.collectionType?.let {
getTypeFor(it)?.let {
listOf(it)
}
},
collectionType =
library?.collectionType
?: CollectionType.UNKNOWN,
),
)
}
Success(
title,
genres,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = genres.size >= limit,
)
}
is HomeRowConfig.Studios -> {
val request =
GetStudiosRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
includeItemTypes = listOf(BaseItemKind.SERIES),
)
val items =
GetStudiosRequestHandler
.execute(api, request)
.content.items
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
?: ResStringProvider(R.string.studios)
val studios =
items.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
)
BaseItem(
it,
false,
imageUrl,
createStudioDestination(
studioId = it.id,
name = it.name ?: "",
parentId = row.parentId,
parentName = library?.name,
includeItemTypes =
library?.collectionType?.let {
getTypeFor(it)?.let {
listOf(it)
}
},
),
)
}
Success(
title,
studios,
viewOptions = row.viewOptions,
showViewMore = studios.size >= limit,
)
}
is HomeRowConfig.RecentlyAdded -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library?.name)
val request =
GetLatestMediaRequest(
fields = library.itemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
limit = limit,
sortBy =
listOf(
ItemSortBy.PREMIERE_DATE,
ItemSortBy.SERIES_SORT_NAME,
ItemSortBy.AIRED_EPISODE_ORDER,
),
sortOrder =
listOf(
SortOrder.DESCENDING,
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
fields = library.itemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.from(it, api, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
parentId = row.parentId,
recursive = row.recursive,
sortBy =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(ItemSortBy.SORT_NAME)
add(ItemSortBy.RANDOM)
} else {
add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
}
}
},
sortOrder =
row.sort?.let {
buildList {
if (it.sort == ItemSortBy.RANDOM) {
add(SortOrder.ASCENDING)
add(it.direction)
} else {
add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
}
}
},
limit = limit,
fields = library.itemFields,
)
// Not using getItemName because we want to throw the 404
val title =
api.userLibraryApi
.getItem(
userId = serverRepository.currentUser?.id,
itemId = row.parentId,
).content.name
?.let { StringStringProvider(it) }
?: ResStringProvider(R.string.collection)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.GetItems -> {
val request =
row.getItems.let {
if (it.limit == null) {
it.copy(
userId = userDto.id,
limit = limit,
)
} else {
it.copy(
userId = userDto.id,
)
}
}
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
StringStringProvider(row.name),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Favorite -> {
val title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(formatTypeName(row.kind)),
)
val resultList =
when (row.kind) {
BaseItemKind.PERSON -> {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler.execute(api, request).toBaseItems()
}
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
}
resultList.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = HomeItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
enableTotalRecordCount = false,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetRecordingsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getRecordings(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.active_recordings),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetProgramsDto(
userId = userDto.id,
fields = ProgramItemFields,
limit = limit,
enableUserData = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetProgramsDtoHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
ResStringProvider(R.string.watch_live),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.TvChannels -> {
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
fields = HomeItemFields,
limit = limit,
enableImages = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetLiveTvChannelsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
api.liveTvApi
.getLiveTvChannels(request)
.toBaseItems(api, row.viewOptions.useSeries)
}.let {
Success(
ResStringProvider(R.string.channels),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}
is HomeRowConfig.Suggestions -> {
val library =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
if (itemKind != null) {
val suggestions =
suggestionService
.getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull()
when (suggestions) {
SuggestionsResource.Empty -> {
Success(
title,
listOf(),
row.viewOptions,
rowType = row,
)
}
is SuggestionsResource.Success -> {
Success(
title,
suggestions.items,
row.viewOptions,
rowType = row,
showViewMore = suggestions.items.size >= limit,
)
}
SuggestionsResource.Loading,
null,
-> {
HomeRowLoadingState.Loading(title)
}
}
} else {
HomeRowLoadingState.Error(
title = title,
message = "Unsupported type ${library.collectionType}",
)
}
}
}
companion object { companion object {
const val CUSTOM_PREF_ID = "home_settings" const val CUSTOM_PREF_ID = "home_settings"
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowTrailingComma = true
}
} }
} }
@ -1229,10 +624,14 @@ data class HomeRowConfigDisplay(
*/ */
data class HomePageResolvedSettings( data class HomePageResolvedSettings(
val userId: UUID, val userId: UUID,
val source: HomePageSettingsSource,
val rows: List<HomeRowConfigDisplay>, val rows: List<HomeRowConfigDisplay>,
) { ) {
fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
companion object { companion object {
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) val EMPTY =
HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
} }
} }
@ -1266,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) } name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added) ?: ResStringProvider(R.string.recently_added)
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) { when (collectionType) {
CollectionType.MUSIC, CollectionType.MUSIC,
@ -1315,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
HomeRowViewOptions() HomeRowViewOptions()
} }
} }
enum class HomePageSettingsSource(
@param:StringRes val stringResId: Int,
) {
UNSET(R.string.unknown),
LOCAL(R.string.home_settings_source_local),
SERVER_PROFILE(R.string.home_settings_source_server_profile),
PLUGIN(R.string.home_settings_source_plugin),
DEFAULT(R.string.home_settings_source_unset),
WEB_CONFIG(R.string.home_settings_source_web),
}

View file

@ -35,6 +35,11 @@ class ImageUrlService
parentBackdropId: UUID? = null, parentBackdropId: UUID? = null,
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
seriesPrimaryTag: String? = null,
parentLogoId: UUID? = null,
parentLogoTag: String? = null,
parentThumbTag: String? = null,
parentBackdropTags: List<String> = emptyList(),
): String? = ): String? =
when (imageType) { when (imageType) {
ImageType.LOGO -> { ImageType.LOGO -> {
@ -44,6 +49,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = parentLogoTag.takeIf { parentLogoId == seriesId },
) )
} else { } else {
getItemImageUrl( getItemImageUrl(
@ -51,6 +57,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = imageTags[imageType],
) )
} }
} }
@ -63,6 +70,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
) )
} else if (backdropTags.isNotEmpty()) { } else if (backdropTags.isNotEmpty()) {
getItemImageUrl( getItemImageUrl(
@ -70,6 +78,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = backdropTags.first(),
) )
} else { } else {
null null
@ -86,6 +95,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = parentThumbTag,
) )
} else if (useSeriesForPrimary && parentBackdropId != null && } else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
@ -96,6 +106,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = parentBackdropTags.firstOrNull(),
) )
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl( getItemImageUrl(
@ -103,6 +114,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = parentThumbTag,
) )
} else if (useSeriesForPrimary && } else if (useSeriesForPrimary &&
parentThumbId == null && parentThumbId == null &&
@ -115,6 +127,7 @@ class ImageUrlService
imageType = ImageType.PRIMARY, imageType = ImageType.PRIMARY,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = imageTags[ImageType.PRIMARY],
) )
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) { } else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available // If no thumb, use backdrop if available
@ -123,6 +136,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = backdropTags.first(),
) )
} else { } else {
getItemImageUrl( getItemImageUrl(
@ -130,6 +144,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = imageTags[imageType],
) )
} }
} }
@ -145,6 +160,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
) )
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl( getItemImageUrl(
@ -152,6 +168,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
) )
} else { } else {
getItemImageUrl( getItemImageUrl(
@ -159,6 +176,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = imageTags[imageType],
) )
} }
} }
@ -169,6 +187,7 @@ class ImageUrlService
imageType = imageType, imageType = imageType,
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
tag = imageTags[imageType],
) )
} }
} }
@ -193,6 +212,11 @@ class ImageUrlService
backdropTags = item.data.backdropImageTags.orEmpty(), backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth, fillWidth = fillWidth,
fillHeight = fillHeight, fillHeight = fillHeight,
seriesPrimaryTag = item.data.seriesPrimaryImageTag,
parentLogoId = item.data.parentLogoItemId,
parentLogoTag = item.data.parentLogoImageTag,
parentThumbTag = item.data.parentThumbImageTag,
parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
) )
} else { } else {
null null

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager import android.app.SearchManager
import android.content.Intent import android.content.Intent
import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
@ -114,6 +116,7 @@ class IntentService
return IntentResult.Target(destinations, true) return IntentResult.Target(destinations, true)
} }
@VisibleForTesting
internal suspend fun prepare(intent: Intent): IntentResult? { internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences val appPrefs = userPreferencesService.flow.first().appPreferences
@ -121,39 +124,33 @@ class IntentService
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) { return if (userId != null && serverId != null) {
Timber.v("Intent switches user") Timber.v("Intent switches user")
val user = serverRepository.serverDao.getUser(serverId, userId) val user = serverRepository.tryChangeUser(serverId, userId)
if (user != null && !user.isProtected) { if (user != null) {
serverRepository.restoreSession(serverId, userId)
?: IntentResult.Error("Error restoring user")
null null
} else { } else {
IntentResult.Error("Cannot switch to specified user") IntentResult.Error("Error restoring user")
} }
} else { } else if (appPrefs.signInAutomatically) {
val profileProtected = Timber.v("No current user, so restoring last")
serverRepository.current.value val result = serverRepository.restoreLastSession()
?.user Timber.v("Restored result=%s", result)
?.isProtected == true when (result) {
if (appPrefs.signInAutomatically && !profileProtected) { RestoredSession.None,
Timber.v("No current user, so restoring last") is RestoredSession.ServerOnly,
val userId = appPrefs.currentUserId?.toUUIDOrNull() -> {
val serverId = appPrefs.currentServerId?.toUUIDOrNull() IntentResult.Error("Could not auto sign-in, specify a server & user")
}
if (userId != null && serverId != null) { is RestoredSession.Success -> {
val current = if (result.currentUser.user.isProtected) {
serverRepository.restoreSession(serverId, userId)
?: return IntentResult.Error("Error restoring user")
if (current.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected") IntentResult.Error("Could not auto sign-in, user is protected")
} else { } else {
null null
} }
} else {
IntentResult.Error("Could not auto sign-in, specify a server & user")
} }
} else {
IntentResult.Error("Auto sign-in not enabled or user is protected")
} }
} else {
IntentResult.Error("Auto sign-in not enabled")
} }
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -52,12 +54,8 @@ class LatestNextUpWorker
try { try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active // Not active
var currentUser = serverRepository.current.value val result = serverRepository.restoreLastSession()
if (currentUser == null) { if (result !is RestoredSession.Success) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run") Timber.w("No user found during run")
return Result.failure() return Result.failure()
} }
@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
) )
// Exposed for testing @VisibleForTesting
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
init { init {

View file

@ -122,7 +122,11 @@ class MusicService
mutex.withLock { mutex.withLock {
if (mediaSession == null) { if (mediaSession == null) {
Timber.i("Starting music MediaSession") Timber.i("Starting music MediaSession")
mediaSession = MediaSession.Builder(context, player).build() mediaSession =
MediaSession
.Builder(context, player)
.setCallback(TrustAllControllersCallback)
.build()
activityTracker = activityTracker =
TrackActivityPlaybackListener(api, player) { TrackActivityPlaybackListener(api, player) {
state.value.currentItemId?.let { itemId -> state.value.currentItemId?.let { itemId ->
@ -327,6 +331,7 @@ class MusicService
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
itemId = albumId, itemId = albumId,
imageType = ImageType.PRIMARY, imageType = ImageType.PRIMARY,
tag = audio.data.albumPrimaryImageTag,
) )
} }
return MediaItem return MediaItem

View file

@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -32,9 +33,15 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.BitstreamFilter
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.factory.AssRenderersFactory
@ -57,6 +64,7 @@ class PlayerFactory
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val deviceProfileService: DeviceProfileService,
) { ) {
@Volatile @Volatile
var currentPlayer: Player? = null var currentPlayer: Player? = null
@ -94,10 +102,16 @@ class PlayerFactory
val preferDolbyVision = val preferDolbyVision =
appPreferences.experimentalPreferences appPreferences.experimentalPreferences
.get { preferDolbyVisionOverHdr10Plus } ?: false .get { preferDolbyVisionOverHdr10Plus } ?: false
val convertDolbyVisionProfile7 =
DolbyVisionProfile7Conversion.isWanted(
appPreferences.experimentalPreferences,
deviceProfileService.mediaCodecCapabilitiesTest,
)
Timber.v( Timber.v(
"extensions=%s, assPlaybackMode=%s", "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
extensions, extensions,
prefs.overrides.assPlaybackMode, prefs.overrides.assPlaybackMode,
convertDolbyVisionProfile7,
) )
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
@ -108,9 +122,18 @@ class PlayerFactory
} }
val dataSourceFactory = DefaultDataSource.Factory(context) val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = createExtractorsFactory() val extractorsFactory = createExtractorsFactory()
// Applied outermost so that a profile 7 track is advertised as 8.1 whichever
// extractor, media3's own or libass's Matroska replacement, produced it
fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
var renderersFactory: RenderersFactory = var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) WholphinRenderersFactory(
.setEnableDecoderFallback(true) context,
decodeAv1,
preferDolbyVision,
convertDolbyVisionProfile7,
).setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode) .setExtensionRendererMode(rendererMode)
val mediaSourceFactory = val mediaSourceFactory =
@ -126,15 +149,16 @@ class PlayerFactory
renderersFactory = AssRenderersFactory(assHandler, renderersFactory) renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory( DefaultMediaSourceFactory(
dataSourceFactory, dataSourceFactory,
extractorsFactory.withAssMkvSupport( extractorsFactory
assSubtitleParserFactory, .withAssMkvSupport(
assHandler, assSubtitleParserFactory,
), assHandler,
).forDolbyVision(),
).setSubtitleParserFactory(assSubtitleParserFactory) ).setSubtitleParserFactory(assSubtitleParserFactory)
} else { } else {
DefaultMediaSourceFactory( DefaultMediaSourceFactory(
dataSourceFactory, dataSourceFactory,
extractorsFactory, extractorsFactory.forDolbyVision(),
) )
} }
val disableAudioOffload = val disableAudioOffload =
@ -160,8 +184,9 @@ class PlayerFactory
AudioAttributes AudioAttributes
.Builder() .Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build(), .build(),
false, true,
) )
} }
} }
@ -246,9 +271,29 @@ class PlayerFactory
fun createMediaSession(player: Player) = fun createMediaSession(player: Player) =
MediaSession MediaSession
.Builder(context, player) .Builder(context, player)
.setCallback(TrustAllControllersCallback)
.build() .build()
} }
/**
* Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`)
* only get read-only player commands by default, so play/pause from them is silently dropped.
* Restore the pre-1.11 behavior of granting all player commands to every controller.
*/
@OptIn(UnstableApi::class)
object TrustAllControllersCallback : MediaSession.Callback {
override fun onConnectAsync(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaSession.ConnectionResult> =
Futures.immediateFuture(
MediaSession.ConnectionResult
.AcceptedResultBuilder(session, controller)
.setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
.build(),
)
}
val Player.isReleased: Boolean val Player.isReleased: Boolean
get() { get() {
return when (this) { return when (this) {
@ -268,6 +313,7 @@ class WholphinRenderersFactory(
context: Context, context: Context,
private val av1Enabled: Boolean, private val av1Enabled: Boolean,
private val preferDolbyVisionOverHdr10Plus: Boolean = false, private val preferDolbyVisionOverHdr10Plus: Boolean = false,
private val convertDolbyVisionProfile7: Boolean = false,
) : DefaultRenderersFactory(context) { ) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class) @OptIn(ExperimentalApi::class)
override fun buildVideoRenderers( override fun buildVideoRenderers(
@ -280,12 +326,16 @@ class WholphinRenderersFactory(
allowedVideoJoiningTimeMs: Long, allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>, out: ArrayList<Renderer>,
) { ) {
// The conversion goes first: it may shorten the access unit, and the masking then only
// has to walk what is left of it
val bitstreamFilters =
buildList<BitstreamFilter> {
if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
}
val videoCodecAdapterFactory = val videoCodecAdapterFactory =
if (preferDolbyVisionOverHdr10Plus) { if (bitstreamFilters.isNotEmpty()) {
BitstreamFilteringCodecAdapterFactory( BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
codecAdapterFactory,
listOf(Hdr10PlusMaskingFilter()),
)
} else { } else {
codecAdapterFactory codecAdapterFactory
} }

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.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrPluginLogin
import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -39,26 +43,25 @@ class SeerrServerRepository
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) { ) {
private val _connection = private val _connection = MutableStateFlow<SeerrState>(SeerrState())
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured) val connection: StateFlow<SeerrState> = _connection
val connection: StateFlow<SeerrConnectionStatus> = _connection
val current: Flow<CurrentSeerr?> = val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current } _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> = val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> = val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id } val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/** /**
* Whether Seerr integration is currently active of not * Whether Seerr integration is currently active of not
*/ */
val active: Flow<Boolean> = val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() { fun clear() {
_connection.update { SeerrConnectionStatus.NotConfigured } _connection.update { SeerrState() }
seerrApi.update("", null) seerrApi.update("", null)
} }
@ -67,7 +70,7 @@ class SeerrServerRepository
user: SeerrUser, user: SeerrUser,
exception: Exception, exception: Exception,
) { ) {
_connection.update { SeerrConnectionStatus.Error(server, user, exception) } _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
seerrApi.update("", null) seerrApi.update("", null)
} }
@ -78,8 +81,9 @@ class SeerrServerRepository
) { ) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_connection.update { _connection.update {
SeerrConnectionStatus.Success( it.copy(
CurrentSeerr(server, user, userConfig, publicSettings), connection =
SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
) )
} }
} }
@ -169,7 +173,7 @@ class SeerrServerRepository
suspend fun removeServerForCurrentUser(): Boolean { suspend fun removeServerForCurrentUser(): Boolean {
val user = val user =
when (val conn = connection.first()) { when (val conn = connection.value.connection) {
SeerrConnectionStatus.NotConfigured -> return false SeerrConnectionStatus.NotConfigured -> return false
is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Error -> conn.user
is SeerrConnectionStatus.Success -> conn.current.user is SeerrConnectionStatus.Success -> conn.current.user
@ -178,6 +182,81 @@ class SeerrServerRepository
clear() clear()
return rows > 0 return rows > 0
} }
/**
* Given the [SeerrPluginSettings], try to connect to the Seerr server
*
* @return true if successfully updated the SeerrApi and logged in
*/
suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
val currentUser = serverRepository.currentUser ?: return false
if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
val server =
SeerrServer(
id = -1,
url = settings.serverUrl,
)
val user =
SeerrUser(
jellyfinUserRowId = currentUser.rowId,
serverId = server.id,
authMethod =
when (settings.login) {
is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
},
username =
when (settings.login) {
is SeerrPluginLogin.ApiKey -> null
is SeerrPluginLogin.Local -> settings.login.local.username
},
password = null,
credential = null,
)
try {
val userConfig =
when (settings.login) {
is SeerrPluginLogin.ApiKey -> {
Timber.v("maybeAddPluginServer, using API key")
seerrApi.update(settings.serverUrl, settings.login.apiKey)
seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
}
is SeerrPluginLogin.Local -> {
Timber.v("maybeAddPluginServer, using local user")
seerrApi.update(settings.serverUrl, null)
seerrLogin(
seerrApi.api,
SeerrAuthMethod.LOCAL,
settings.login.local.username,
settings.login.local.password,
)
}
SeerrPluginLogin.None -> {
return false
}
}
// Success
_connection.update { it.copy(pluginProvided = true) }
set(server, user, userConfig)
return true
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
clear()
Timber.w(ex, "Error adding plugin provided seerr")
_connection.update {
it.copy(
pluginProvided = true,
connection = SeerrConnectionStatus.Error(server, user, ex),
)
}
return false
}
}
return false
}
} }
/** /**
@ -185,6 +264,11 @@ class SeerrServerRepository
*/ */
typealias SeerrUserConfig = User typealias SeerrUserConfig = User
data class SeerrState(
val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
val pluginProvided: Boolean = false,
)
sealed interface SeerrConnectionStatus { sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus data object NotConfigured : SeerrConnectionStatus
@ -205,11 +289,15 @@ data class CurrentSeerr(
val config: SeerrUserConfig, val config: SeerrUserConfig,
val serverConfig: PublicSettings, val serverConfig: PublicSettings,
) { ) {
val requestMovieEnabled: Boolean
get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
val request4kMovieEnabled: Boolean val request4kMovieEnabled: Boolean
get() = get() =
(serverConfig.movie4kEnabled ?: false) && (serverConfig.movie4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
val requestTvEnabled: Boolean
get() = config.hasPermission(SeerrPermission.REQUEST_TV)
val request4kTvEnabled: Boolean val request4kTvEnabled: Boolean
get() = get() =
(serverConfig.series4kEnabled ?: false) && (serverConfig.series4kEnabled ?: false) &&

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 package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.RelativeMediaStream
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.asRelative
import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.preferences.UserProfileSettings
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -30,50 +42,69 @@ class StreamChoiceService
@Inject @Inject
constructor( constructor(
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) { ) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
suspend fun updateAudio( suspend fun saveSeriesTrackChoice(
dto: BaseItemDto, dto: BaseItemDto,
audioLang: String, type: MediaStreamType,
) = update(dto) { stream: RelativeMediaStream?,
it.copy( activation: ActivationFlag = ActivationFlag.ACTIVATED,
audioLanguage = audioLang,
)
}
suspend fun updateSubtitles(
dto: BaseItemDto,
subtitleLang: String?,
subtitlesDisabled: Boolean,
) = update(dto) {
it.copy(
subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)
}
suspend fun update(
dto: BaseItemDto,
update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
) { ) {
val seriesId = dto.seriesId val type =
if (seriesId != null) { when (stream?.type ?: type) {
val userId = serverRepository.currentUser!!.rowId MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
val currentPlc = MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
playbackLanguageChoiceDao.get(userId, seriesId) else -> return
?: PlaybackLanguageChoice(userId, seriesId, dto.id) }
val newPlc = update.invoke(currentPlc) val userId = serverRepository.currentUser!!.rowId
Timber.v("Saving series PLC: %s", newPlc) val newStc =
playbackLanguageChoiceDao.save(newPlc) buildList {
} dto.parentId
?.let { seasonId ->
SeriesTrackChoice(
userId = userId,
itemId = dto.id,
parentId = seasonId,
type = type,
parentType = TrackChoiceParentType.SEASON,
stream = stream,
activation = activation,
)
}?.let(::add)
dto.seriesId
?.let { seriesId ->
SeriesTrackChoice(
userId = userId,
itemId = dto.id,
parentId = seriesId,
type = type,
parentType = TrackChoiceParentType.SERIES,
stream = stream,
activation = activation,
)
}?.let(::add)
}
Timber.d(
"Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
dto.id,
dto.seriesId,
dto.parentId,
newStc,
)
seriesTrackChoiceDao.save(newStc)
} }
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = suspend fun saveDisabledSeriesTrackChoice(
dto.seriesId?.let { dto: BaseItemDto,
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) type: MediaStreamType,
} ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
suspend fun saveOnlyForcedSeriesTrackChoice(
dto: BaseItemDto,
type: MediaStreamType,
) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
/** /**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
@ -104,21 +135,15 @@ class StreamChoiceService
*/ */
suspend fun chooseAudioStream( suspend fun chooseAudioStream(
source: MediaSourceInfo, source: MediaSourceInfo,
seriesId: UUID?, item: BaseItemDto,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?, stc: List<SeriesTrackChoice>?,
prefs: UserPreferences, prefs: UserPreferences,
): MediaStream? { ): StreamChoiceResult? {
val plc = val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser!!.rowId,
it,
)
}
return source.mediaStreams?.letNotEmpty { streams -> return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO } val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
chooseAudioStream(candidates, itemPlayback, plc, prefs) chooseAudioStream(candidates, itemPlayback, stc, prefs)
} }
} }
@ -128,59 +153,70 @@ class StreamChoiceService
fun chooseAudioStream( fun chooseAudioStream(
candidates: List<MediaStream>, candidates: List<MediaStream>,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?, stc: List<SeriesTrackChoice>,
prefs: UserPreferences, prefs: UserPreferences,
): MediaStream? = ): StreamChoiceResult =
if (itemPlayback?.audioIndexEnabled == true) { if (itemPlayback?.audioIndexEnabled == true) {
candidates.firstOrNull { it.index == itemPlayback.audioIndex } candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
} else { StreamChoiceReason.Item(
val seriesLang = itemPlayback,
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } )
// If the user has chosen a different language for the series, prefer that } else if (stc.isNotEmpty()) {
val audioLanguage = val first = stc.first()
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) val result = scoreStreams(candidates, first)
if (audioLanguage.isNotNullOrBlank()) { if (result == null) {
val sorted = null with StreamChoiceReason.from(first)
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) } else if (result.isEmpty() && stc.size > 1) {
sorted.firstOrNull { it.language == audioLanguage && it.isDefault } // SeriesTrackChoice did not apply to any streams, but there are more options
?: sorted.firstOrNull { it.language == audioLanguage } chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
?: sorted.firstOrNull { it.isDefault } } else if (result.isEmpty()) {
?: sorted.firstOrNull() // SeriesTrackChoice did not apply to any streams, so use regular selection logic
Timber.v("No audio STC applied")
chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
} else { } else {
candidates.firstOrNull { it.isDefault } // Otherwise, use the best scored stream
?: candidates.firstOrNull() Timber.v("Using audio STC from %s", first.parentId)
result.first().stream with StreamChoiceReason.from(first, result)
} }
} else {
val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
val stream =
if (audioLanguage.isNotNullOrBlank()) {
val sorted =
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
?: sorted.firstOrNull { it.language == audioLanguage }
?: sorted.firstOrNull { it.isDefault }
?: sorted.firstOrNull()
} else {
candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull()
}
stream with StreamChoiceReason.UserPreferences
} }
/** /**
* Returns the subtitle stream that should play * Returns the subtitle stream that should play
*/ */
suspend fun chooseSubtitleStream( suspend fun chooseSubtitleStream(
item: BaseItemDto,
source: MediaSourceInfo, source: MediaSourceInfo,
audioStream: MediaStream?, audioStream: MediaStream?,
seriesId: UUID?,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
plc: PlaybackLanguageChoice?, stc: List<SeriesTrackChoice>?,
prefs: UserPreferences, prefs: UserPreferences,
): MediaStream? { ): StreamChoiceResult? =
val plc = source.mediaStreams?.letNotEmpty { streams ->
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser!!.rowId,
it,
)
}
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
chooseSubtitleStream( chooseSubtitleStream(
audioStream?.language, audioStream?.language,
candidates, candidates,
itemPlayback, itemPlayback,
plc, stc,
prefs, prefs,
) )
} }
}
/** /**
* Resolves ONLY_FORCED to an actual subtitle track index. * Resolves ONLY_FORCED to an actual subtitle track index.
@ -189,7 +225,7 @@ class StreamChoiceService
suspend fun resolveSubtitleIndex( suspend fun resolveSubtitleIndex(
source: MediaSourceInfo, source: MediaSourceInfo,
audioStreamIndex: Int?, audioStreamIndex: Int?,
seriesId: UUID?, item: BaseItemDto,
subtitleIndex: Int, subtitleIndex: Int,
prefs: UserPreferences, prefs: UserPreferences,
): Int? = ): Int? =
@ -203,17 +239,17 @@ class StreamChoiceService
val itemPlayback = val itemPlayback =
ItemPlayback( ItemPlayback(
userId = serverRepository.currentUser!!.rowId, userId = serverRepository.currentUser!!.rowId,
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution itemId = item.id,
subtitleIndex = TrackIndex.ONLY_FORCED, subtitleIndex = TrackIndex.ONLY_FORCED,
) )
chooseSubtitleStream( chooseSubtitleStream(
item = item,
source = source, source = source,
audioStream = audioStream, audioStream = audioStream,
seriesId = seriesId,
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
plc = null, stc = null,
prefs = prefs, prefs = prefs,
)?.index )?.stream?.index
} }
/** /**
@ -223,47 +259,81 @@ class StreamChoiceService
audioStreamLang: String?, audioStreamLang: String?,
candidates: List<MediaStream>, candidates: List<MediaStream>,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?, stc: List<SeriesTrackChoice>,
prefs: UserPreferences, prefs: UserPreferences,
): MediaStream? { ): StreamChoiceResult {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
return null if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
} return null with StreamChoiceReason.Item(itemPlayback)
val seriesLang = } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } val subtitleLanguage =
val subtitleLanguage = getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) // Client-side manual override: User selected "Only Forced" in player menu
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { return findForcedTrack(
// Client-side manual override: User selected "Only Forced" in player menu candidates,
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) subtitleLanguage,
} else if (itemPlayback?.subtitleIndexEnabled == true) { audioStreamLang,
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } ) with StreamChoiceReason.Item(itemPlayback)
} else {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
StreamChoiceReason.Item(itemPlayback)
}
} else if (stc.isNotEmpty()) {
val first = stc.first()
return when (first.activation) {
ActivationFlag.DISABLED -> {
null with StreamChoiceReason.from(first)
}
ActivationFlag.ONLY_FORCED -> {
val subtitleLanguage =
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val stream =
findForcedTrack(
candidates,
subtitleLanguage,
audioStreamLang,
)
if (stream != null) {
stream with StreamChoiceReason.from(first)
} else {
handleSubtitleStreamChoiceResult(
result = emptyList(),
audioStreamLang = audioStreamLang,
candidates = candidates,
itemPlayback = itemPlayback,
stc = stc,
prefs = prefs,
)
}
}
ActivationFlag.ACTIVATED -> {
val result =
scoreStreams(candidates, first)
?: throw IllegalStateException("scoreStreams should not return null for activated")
handleSubtitleStreamChoiceResult(
result = result,
audioStreamLang = audioStreamLang,
candidates = candidates,
itemPlayback = itemPlayback,
stc = stc,
prefs = prefs,
)
}
}
} else { } else {
val subtitleLanguage =
getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val subtitleMode = val subtitleMode =
when { when (prefs.userPreferences?.subtitleMode) {
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
// User has chosen a series level subtitle language, so override their normal SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
// subtitle mode to display that language SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitlePlaybackMode.ALWAYS SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
} SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { null -> SubtitlePlaybackMode.DEFAULT
// Series level settings disables subtitles
SubtitlePlaybackMode.NONE
}
else -> {
// Fallback to the user's preference
when (prefs.userPreferences?.subtitleMode) {
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
null -> SubtitlePlaybackMode.DEFAULT
}
}
} ?: SubtitlePlaybackMode.DEFAULT } ?: SubtitlePlaybackMode.DEFAULT
val candidates = val candidates =
candidates candidates
@ -277,73 +347,104 @@ class StreamChoiceService
}.thenByDescending { it.isForced && it.language.isUnknown } }.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced }, .thenByDescending { it.isForced },
) )
return when (subtitleMode) { val stream =
SubtitlePlaybackMode.ALWAYS -> { when (subtitleMode) {
if (subtitleLanguage.isNotNullOrBlank()) { SubtitlePlaybackMode.ALWAYS -> {
candidates.firstOrNull { if (subtitleLanguage.isNotNullOrBlank()) {
// Prefer non-forced first candidates.firstOrNull {
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) // Prefer non-forced first
} ?: candidates.firstOrNull { !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
it.language.equalsLangOrUnknown(subtitleLanguage) } ?: candidates.firstOrNull {
} it.language.equalsLangOrUnknown(subtitleLanguage)
} else { }
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
}
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else { } else {
// Otherwise, show forced subtitles in preferred lang candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
} }
} else { }
candidates.firstOrNull { it.isDefault }
SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else {
candidates.firstOrNull { it.isForced }
}
}
SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage.isNotNullOrBlank()) {
val audioLanguage = userConfig?.audioLanguagePreference
if (
// Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
(audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
// Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
(audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
) {
candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
// Otherwise, show forced subtitles in preferred lang
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else {
candidates.firstOrNull { it.isDefault }
}
}
SubtitlePlaybackMode.DEFAULT -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
?: candidates.firstOrNull { it.isDefault || it.isForced }
} else {
candidates.firstOrNull { it.isDefault || it.isForced }
}
}
SubtitlePlaybackMode.NONE -> {
null
} }
} }
return stream with StreamChoiceReason.UserPreferences
SubtitlePlaybackMode.DEFAULT -> {
if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
?: candidates.firstOrNull { it.isDefault || it.isForced }
} else {
candidates.firstOrNull { it.isDefault || it.isForced }
}
}
SubtitlePlaybackMode.NONE -> {
null
}
}
} }
} }
/** Returns true if the track is forced (via metadata flag or title patterns). */ /**
private fun isForcedOrSigns(track: MediaStream): Boolean { * Recursively calls [chooseSubtitleStream] based on [result] and [stc]
if (track.isForced) return true */
val title = track.title ?: track.displayTitle ?: return false private fun handleSubtitleStreamChoiceResult(
return title.contains("forced", ignoreCase = true) || result: List<ScoredMediaStream>,
title.contains("signs", ignoreCase = true) || audioStreamLang: String?,
title.contains("songs", ignoreCase = true) candidates: List<MediaStream>,
itemPlayback: ItemPlayback?,
stc: List<SeriesTrackChoice>,
prefs: UserPreferences,
): StreamChoiceResult {
val first = stc.first()
return if (result.isEmpty() && stc.size > 1) {
// SeriesTrackChoice did not apply to any streams, but there are more options
chooseSubtitleStream(
audioStreamLang,
candidates,
itemPlayback,
stc.subList(1, stc.size),
prefs,
)
} else if (result.isEmpty()) {
// SeriesTrackChoice did not apply to any streams, so use regular selection logic
Timber.v("No subtitle STC applied")
chooseSubtitleStream(
audioStreamLang,
candidates,
itemPlayback,
emptyList(),
prefs,
)
} else {
// Otherwise, use the best scored stream
Timber.v("Using subtitle STC from %s", first.parentId)
result.first().stream with StreamChoiceReason.from(first, result)
}
} }
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
@ -367,19 +468,92 @@ class StreamChoiceService
// 3. Unknown language forced track // 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
} }
suspend fun getSeriesTrackChoices(
seriesId: UUID?,
seasonId: UUID?,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice> {
val userId = serverRepository.currentUser?.rowId ?: return emptyList()
return when {
seriesId != null && seasonId != null -> {
seriesTrackChoiceDao.get(
userId = userId,
seasonId = seasonId,
seriesId = seriesId,
type = type,
)
}
seriesId != null -> {
seriesTrackChoiceDao.getBySeriesId(
userId = userId,
seriesId = seriesId,
type = type,
)
}
seasonId != null -> {
seriesTrackChoiceDao.getBySeasonId(
userId = userId,
seasonId = seasonId,
type = type,
)
}
else -> {
emptyList()
}
}
}
suspend fun getSeriesTrackChoices(
item: BaseItemDto,
type: SeriesTrackChoiceType,
): List<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type)
suspend fun getSeriesTrackChoices(
item: BaseItemDto,
type: MediaStreamType,
): List<SeriesTrackChoice> =
getSeriesTrackChoices(
seriesId = item.seriesId,
seasonId = item.parentId,
type =
when (type) {
MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
else -> return emptyList()
},
)
} }
private val String?.isUnknown: Boolean private val String?.isUnknown: Boolean
get() = get() =
this == null || this.isNullOrBlank() ||
this.equals("unknown", true) || this.equals("unknown", true) ||
this.equals("und", true) || this.equals("und", true) ||
this.equals("undetermined", true) || this.equals("undetermined", true) ||
this.equals("mul", true) || this.equals("mul", true) ||
this.equals("zxx", true) this.equals("zxx", true)
private val String?.isNotUnknown: Boolean
get() = !this.isUnknown
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true)
/** Returns true if the track is forced (via metadata flag or title patterns). */
fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track)
fun isSigns(track: MediaStream): Boolean {
val title = track.title ?: track.displayTitle ?: return false
return title.contains("forced", ignoreCase = true) ||
title.contains("signs", ignoreCase = true) ||
title.contains("songs", ignoreCase = true)
}
/** /**
* Based on the user's preferences, get their preferred language for audio or subtitles * Based on the user's preferences, get their preferred language for audio or subtitles
* *
@ -412,3 +586,153 @@ fun getPreferredLanguage(
else -> pref else -> pref
}?.takeIf { it.isNotNullOrBlank() } }?.takeIf { it.isNotNullOrBlank() }
} }
/**
* Scores streams based the user's explicit [SeriesTrackChoice]
*
* @return sorted list of preferred streams or null if stream should be disabled
*/
fun scoreStreams(
streams: List<MediaStream>,
choice: SeriesTrackChoice,
): List<ScoredMediaStream>? {
if (choice.activation == ActivationFlag.DISABLED) {
return null
}
val streams =
streams.filter {
if (
(choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) &&
!isForcedOrSigns(it)
) {
// Filter out non-forced tracks if the user only wants forced
return@filter false
}
if (choice.language.isNotUnknown &&
!choice.language.equalsLangExact(it.language)
) {
// Filter out languages that do not match
return@filter false
}
// Filter by type
choice.type.mediaStreamType == it.type
}
val scored =
streams
.asRelative()
.map { s ->
var score = 0
// Prefer exact language match over unknown
if (choice.language.equalsLangExact(s.language)) score += 10_000
if (s.isForced && choice.has(TrackFlag.FORCED)) {
score += 1_000
}
if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) {
score += 500
}
if (s.isDefault && choice.has(TrackFlag.DEFAULT)) {
score += 500
}
if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) {
score += 500
} else if (choice.has(TrackFlag.SDH) && s.isForced) {
// If user wants SDH, prefer non-forced "regular" over forced
score -= 10
}
if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) {
score += 100
}
if (s.relativeIndex == choice.relativeTrackIndex) {
score += 100
}
if (s.stream.title == choice.title) {
score += 10
}
if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) {
score += 100
} else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) {
score += 50
}
if (s.isDefault) {
// All else being equal, prefer the default track
score += 1
}
ScoredMediaStream(score, s.stream)
}.sortedByDescending { it.score }
return scored
}
data class StreamChoiceResult(
val stream: MediaStream?,
val reason: StreamChoiceReason,
)
data class ScoredMediaStream(
val score: Int,
val stream: MediaStream,
)
private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason)
/**
* Why a particular [MediaStream] was chosen
*/
sealed interface StreamChoiceReason {
val description: StringProvider
val scores: List<ScoredMediaStream> get() = emptyList()
data object Unknown : StreamChoiceReason {
override val description: StringProvider = ResStringProvider(R.string.unknown)
}
data object UserPreferences : StreamChoiceReason {
override val description: StringProvider =
ResStringProvider(R.string.profile_specific_settings)
}
data class Series(
val stc: SeriesTrackChoice,
override val scores: List<ScoredMediaStream>,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
}
data class Season(
val stc: SeriesTrackChoice,
override val scores: List<ScoredMediaStream>,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER)
}
data class Item(
val itemPlayback: ItemPlayback,
) : StreamChoiceReason {
override val description: StringProvider =
ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER)
}
companion object {
fun from(
stc: SeriesTrackChoice,
scores: List<ScoredMediaStream> = emptyList(),
) = when (stc.parentType) {
TrackChoiceParentType.SERIES -> Series(stc, scores)
TrackChoiceParentType.SEASON -> Season(stc, scores)
TrackChoiceParentType.DEPRECATED -> Unknown
TrackChoiceParentType.UNKNOWN -> Unknown
}
private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) }
private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) }
private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) }
}
}

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -63,12 +64,8 @@ class SuggestionsWorker
} }
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
var currentUser = serverRepository.current.value val result = serverRepository.restoreLastSession()
if (currentUser == null) { if (result !is RestoredSession.Success) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run") Timber.w("No user found during run")
return Result.failure() return Result.failure()
} }

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Listens for JF user switching in the app to also switch other settings like Seerr user/server * Listens for JF user switching in the app to also switch other settings like Seerr user/server
@ -36,6 +37,7 @@ class UserSwitchListener
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi, private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService, private val homeSettingsService: HomeSettingsService,
private val serverPluginApi: ServerPluginApi,
) { ) {
init { init {
context as AppCompatActivity context as AppCompatActivity
@ -65,49 +67,78 @@ class UserSwitchListener
AppCompatDelegate.setApplicationLocales(localeList) AppCompatDelegate.setApplicationLocales(localeList)
} }
// Check for home settings // Check if plugin is installed, then for home settings & seerr settings
launchIO { launchIO {
homeSettingsService.loadCurrentSettings(user.id) val serverPluginInstalled =
} try {
if (BuildConfig.DISCOVER_ENABLED) { serverPluginApi.checkInstalled()
// Check for seerr server } catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error checking for server plugin")
false
}
Timber.i("Server plugin installed: %s", serverPluginInstalled)
serverRepository.serverPluginInstalled.value = serverPluginInstalled
launchIO { launchIO {
seerrServerDao // Check for home settings
.getUsersByJellyfinUser(user.rowId) homeSettingsService.loadCurrentSettings(user)
.lastOrNull() }
?.let { seerrUser ->
val server = if (BuildConfig.DISCOVER_ENABLED) {
seerrServerDao.getServer(seerrUser.serverId)?.server // Check for seerr server
if (server != null) { launchIO {
Timber.i("Found a seerr user & server") if (serverPluginInstalled) {
try { serverPluginApi.fetchSeerrSettings()?.let {
seerrApi.update(server.url, seerrUser.credential) Timber.d("Trying seerr auto login")
val userConfig = val result = seerrServerRepository.usePluginProvidedServer(it)
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { if (result) {
seerrLogin( // Successfully used server plugin provided seerr
seerrApi.api, Timber.i("Seerr auto login succeeded")
seerrUser.authMethod, return@launchIO
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server, seerrUser, ex)
} }
} }
} }
Timber.v("Checking for existing configured seerr server")
// Either no server plugin seerr or there was an issue with it
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
seerrLogin(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server, seerrUser, ex)
}
}
}
}
} }
} }
} }

View file

@ -4,6 +4,8 @@ import android.content.Context
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
@ -188,6 +190,12 @@ object AppModule {
fun seerrApi( fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient) ) = SeerrApi(okHttpClient)
@Provides
@Singleton
fun mostRecentServerProvider(
@ApplicationContext context: Context,
): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
} }
@Module @Module

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.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -79,6 +80,10 @@ object DatabaseModule {
@Singleton @Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides
@Singleton
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
@ -70,12 +71,8 @@ class TvProviderWorker
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active // Not active
var currentUser = serverRepository.current.value val result = serverRepository.restoreLastSession()
if (currentUser == null) { if (result !is RestoredSession.Success) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run") Timber.w("No user found during run")
return Result.failure() return Result.failure()
} }

View file

@ -8,6 +8,9 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
@ -205,6 +208,13 @@ fun formatBytes(
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
fun formatMaxBitrate(bitrate: Long) =
if (bitrate < MEGA_BIT) {
"${bitrate / 1024} kbps"
} else {
"${bitrate / MEGA_BIT} Mbps"
}
@get:StringRes @get:StringRes
val MediaSegmentType.stringRes: Int val MediaSegmentType.stringRes: Int
get() = get() =
@ -300,3 +310,55 @@ fun Resources.formatDuration(duration: Duration): String =
else -> getString(R.string.duration_seconds, seconds) else -> getString(R.string.duration_seconds, seconds)
} }
} }
fun Resources.serverSupportedMessage(
serverVersionSupported: ServerVersionSupported,
serverVersion: ServerVersion?,
): String? {
val errorMsg =
when (serverVersionSupported) {
ServerVersionSupported.SUPPORTED -> {
null
}
ServerVersionSupported.NOT_SUPPORTED,
ServerVersionSupported.UNKNOWN,
-> {
getString(R.string.server_version_not_supported)
}
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
getString(R.string.server_version_not_supported_future)
}
}
return errorMsg?.let {
val msg =
if (serverVersion != null) {
"$it: $serverVersion"
} else {
it + ": " + getString(R.string.unknown)
}
msg + "\n" +
when (serverVersionSupported) {
ServerVersionSupported.SUPPORTED -> {
""
}
ServerVersionSupported.NOT_SUPPORTED,
ServerVersionSupported.UNKNOWN,
-> {
getString(
R.string.server_version_required,
WholphinApplication.minimumServerVersion.toString(),
)
}
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
getString(
R.string.server_version_required,
WholphinApplication.newMinimumServerVersion.toString(),
)
}
}
}
}

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.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -53,19 +52,18 @@ import kotlin.time.Duration
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CollectionFolderGrid( fun CollectionFolderViewScope.CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
collectionType: CollectionType?,
focusedItem: BaseItem?, focusedItem: BaseItem?,
items: List<BaseItem?>, items: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int, initialPosition: Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
@ -99,18 +97,8 @@ fun CollectionFolderGrid(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition, initialPosition = initialPosition,
positionCallback = positionCallback, positionCallback = positionCallback,
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> cardContent = { details ->
GridCard( cardContent.invoke(this@CollectionFolderGrid, details)
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
}, },
columns = viewOptions.columns, columns = viewOptions.columns,
spacing = viewOptions.spacing.dp, spacing = viewOptions.spacing.dp,
@ -210,10 +198,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
} }
CollectionType.HOMEVIDEOS -> { CollectionType.PHOTOS,
listOf(BaseItemKind.VIDEO) CollectionType.HOMEVIDEOS,
listOf(BaseItemKind.PHOTO) -> {
listOf(BaseItemKind.PHOTO_ALBUM) listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
} }
CollectionType.MUSIC -> { CollectionType.MUSIC -> {
@ -236,10 +224,6 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.PLAYLIST) listOf(BaseItemKind.PLAYLIST)
} }
CollectionType.PHOTOS -> {
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
CollectionType.LIVETV -> { CollectionType.LIVETV -> {
listOf( listOf(
BaseItemKind.TV_CHANNEL, BaseItemKind.TV_CHANNEL,

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun CollectionFolderList( fun CollectionFolderViewScope.CollectionFolderList(
preferences: UserPreferences, preferences: UserPreferences,
collectionType: CollectionType?, collectionType: CollectionType?,
focusedItem: BaseItem?, focusedItem: BaseItem?,
@ -84,10 +86,10 @@ fun CollectionFolderList(
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int, initialPosition: Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
@ -153,10 +155,8 @@ fun CollectionFolderList(
).padding(8.dp), ).padding(8.dp),
) )
} }
CollectionFolderContent( this@CollectionFolderList.CollectionFolderListContent(
items = items, items = items,
viewOptions = viewOptions,
collectionType = collectionType,
listState = listState, listState = listState,
position = position, position = position,
positionFocusRequester = positionFocusRequester, positionFocusRequester = positionFocusRequester,
@ -167,6 +167,7 @@ fun CollectionFolderList(
}, },
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
itemContent = itemContent,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@ -266,44 +267,43 @@ fun CollectionFolderListDetails(
} }
@Composable @Composable
fun CollectionFolderContent( fun CollectionFolderViewScope.CollectionFolderListContent(
items: List<BaseItem?>, items: List<BaseItem?>,
viewOptions: ViewOptions,
collectionType: CollectionType?,
listState: LazyListState, listState: LazyListState,
position: Int, position: Int,
positionFocusRequester: FocusRequester, positionFocusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
onPositionChange: (Int) -> Unit, onPositionChange: (Int) -> Unit,
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentPosition by rememberUpdatedState(position)
LazyColumn( LazyColumn(
state = listState, state = listState,
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
modifier = modifier.focusRestorer(positionFocusRequester), modifier = modifier.focusRestorer(positionFocusRequester),
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val onClick: () -> Unit = val details =
remember(index, item) { remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
{ item?.let { onClickItem.invoke(index, item) } } GridItemDetails(
item = item,
index = index,
onClick = { item?.let { onClickItem.invoke(index, item) } },
onLongClick = { item?.let { onLongClickItem.invoke(index, item) } },
widthPx = -1,
mod =
Modifier
.onFocusChanged {
if (it.isFocused) onPositionChange.invoke(index)
}.ifElse(
index == currentPosition,
Modifier.focusRequester(positionFocusRequester),
),
)
} }
val onLongClick: () -> Unit = itemContent.invoke(this@CollectionFolderListContent, details)
remember(index, item) {
{ item?.let { onLongClickItem.invoke(index, item) } }
}
CollectionFolderListItem(
item = item,
dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
collectionType = collectionType,
onClick = onClick,
onLongClick = onLongClick,
modifier =
Modifier
.onFocusChanged {
if (it.isFocused) onPositionChange.invoke(index)
}.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)),
)
} }
} }
} }
@ -522,9 +522,12 @@ fun ListItemAlbum(
} }
val supportingContent = val supportingContent =
remember(item, dense) { remember(item, dense) {
if (!dense && item?.subtitle != null) { // Album artist is already shown as the overline
val subtitle =
if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle
if (!dense && subtitle != null) {
@Composable { @Composable {
Text(item.subtitle ?: "") Text(subtitle)
} }
} else { } else {
null null

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.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.GetPersonsHandler
@ -89,12 +94,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@ -146,6 +153,9 @@ class CollectionFolderViewModel
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
val state: StateFlow<CollectionFolderState> = _state val state: StateFlow<CollectionFolderState> = _state
private val artistSource: ArtistSource
get() = state.value.viewOptions.artistSource
var position: Int var position: Int
get() = savedStateHandle.get<Int>("position") ?: 0 get() = savedStateHandle.get<Int>("position") ?: 0
set(value) { set(value) {
@ -168,15 +178,25 @@ class CollectionFolderViewModel
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
libraryDisplayInfoDao.getItem(user, id) libraryDisplayInfoDao.getItem(user, id)
} }
val ancestorDisplayInfo =
if (libraryDisplayInfo?.viewOptions == null && item != null) {
getAncestorDisplayInfo(item)
} else {
null
}
_state.update { _state.update {
it.copy( it.copy(
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions, viewOptions =
libraryDisplayInfo?.viewOptions
?: ancestorDisplayInfo?.viewOptions
?: defaultViewOptions,
) )
} }
val sortAndDirection = val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) { if (collectionFilter.useSavedLibraryDisplayInfo) {
libraryDisplayInfo?.sortAndDirection libraryDisplayInfo?.sortAndDirection
?: ancestorDisplayInfo?.sortAndDirection
} else { } else {
null null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
@ -232,6 +252,25 @@ class CollectionFolderViewModel
} }
} }
/**
* For a sub-folder, returns the saved display info of the closest ancestor that has one
*/
private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? {
if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) {
return null
}
val user = serverRepository.currentUser ?: return null
return try {
api.libraryApi
.getAncestors(item.id, user.id)
.content
.firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) }
} catch (ex: Exception) {
Timber.w(ex, "Error getting ancestors for %s", item.id)
null
}
}
private fun saveLibraryDisplayInfo( private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = state.value.filter, newFilter: GetItemsFilter = state.value.filter,
newSort: SortAndDirection = state.value.sortAndDirection, newSort: SortAndDirection = state.value.sortAndDirection,
@ -257,7 +296,17 @@ class CollectionFolderViewModel
override fun saveViewOptions(viewOptions: ViewOptions) { override fun saveViewOptions(viewOptions: ViewOptions) {
position = 0 position = 0
val artistSourceChanged = viewOptions.artistSource != artistSource
_state.update { it.copy(viewOptions = viewOptions) } _state.update { it.copy(viewOptions = viewOptions) }
if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) {
loadResults(
true,
state.value.sortAndDirection,
recursive,
state.value.filter,
useSeriesForPrimary,
)
}
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions) saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showBackdrop) { if (!viewOptions.showBackdrop) {
@ -269,11 +318,19 @@ class CollectionFolderViewModel
override fun onClickRandom() { override fun onClickRandom() {
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
val random = val state = state.value
(state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() val list = state.items.successValue as? BlockingList<BaseItem?>
Timber.d("Got random item: %s", random?.id) if (list != null) {
random?.destination()?.let { goToRandom(
navigateTo(random.destination()) navigateTo = ::navigateTo,
parentId = itemId,
filter = state.filter,
sortAndDirection = state.sortAndDirection,
recursive = recursive,
list = list,
)
} else {
Timber.w("state.value.items is not successful: %s", state.items)
} }
} catch (ex: CancellationException) { } catch (ex: CancellationException) {
throw ex throw ex
@ -399,13 +456,27 @@ class CollectionFolderViewModel
} }
GetItemsFilterOverride.ARTIST -> { GetItemsFilterOverride.ARTIST -> {
ApiRequestPager( when (artistSource) {
api, ArtistSource.ARTIST -> {
createGetArtistsRequest(filter), ApiRequestPager(
GetArtistsHandler, api,
viewModelScope, createGetArtistsRequest(filter),
useSeriesForPrimary = useSeriesForPrimary, GetArtistsHandler,
) viewModelScope,
useSeriesForPrimary = useSeriesForPrimary,
)
}
ArtistSource.ALBUM_ARTIST -> {
ApiRequestPager(
api,
createGetAlbumArtistsRequest(filter),
GetAlbumArtistsHandler,
viewModelScope,
useSeriesForPrimary = useSeriesForPrimary,
)
}
}
} }
} }
@ -424,6 +495,17 @@ class CollectionFolderViewModel
) )
} }
private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest {
val item = state.value.item.successValue
return filter.applyTo(
GetAlbumArtistsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
fields = SlimItemFields,
),
)
}
private fun createGetItemsRequest( private fun createGetItemsRequest(
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
recursive: Boolean, recursive: Boolean,
@ -495,18 +577,37 @@ class CollectionFolderViewModel
val filter = state.value.filter val filter = state.value.filter
when (filter.override) { when (filter.override) {
GetItemsFilterOverride.ARTIST -> { GetItemsFilterOverride.ARTIST -> {
GetArtistsHandler.countMatching( when (artistSource) {
api = api, ArtistSource.ARTIST -> {
request = GetArtistsHandler.countMatching(
createGetArtistsRequest(filter).copy( api = api,
enableImageTypes = null, request =
fields = null, createGetArtistsRequest(filter).copy(
nameLessThan = letter.toString(), enableImageTypes = null,
limit = 0, fields = null,
enableTotalRecordCount = true, nameLessThan = letter.toString(),
enableUserData = false, limit = 0,
), enableTotalRecordCount = true,
) enableUserData = false,
),
)
}
ArtistSource.ALBUM_ARTIST -> {
GetAlbumArtistsHandler.countMatching(
api = api,
request =
createGetAlbumArtistsRequest(filter).copy(
enableImageTypes = null,
fields = null,
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
enableUserData = false,
),
)
}
}
} }
// GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
@ -813,6 +914,33 @@ fun CollectionFolderViewContent(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null, focusRequesterOnEmpty: FocusRequester? = null,
gridCardContent: @Composable CollectionFolderViewScope.(
GridItemDetails<BaseItem>,
) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},
listItemContent: @Composable CollectionFolderViewScope.(
GridItemDetails<BaseItem>,
) -> Unit = { (item, index, onClick, onLongClick, _, mod) ->
CollectionFolderListItem(
item = item,
dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
collectionType = collectionType,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
)
},
) { ) {
var position by rememberInt(savedPosition) var position by rememberInt(savedPosition)
@ -975,11 +1103,22 @@ fun CollectionFolderViewContent(
focusRequester?.tryRequestFocus() focusRequester?.tryRequestFocus()
clickedRandom = false clickedRandom = false
} }
val scope =
object : CollectionFolderViewScope {
override val viewOptions: ViewOptions
get() = state.viewOptions
override val collectionType: CollectionType?
get() = item?.data?.collectionType
override val sortAndDirection: SortAndDirection
get() = state.sortAndDirection
override val collectionSize: Int
get() = pager.data.size
}
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) { if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid( scope.CollectionFolderGrid(
preferences = preferences, preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = savedPosition, initialPosition = savedPosition,
items = pager.data, items = pager.data,
sortAndDirection = state.sortAndDirection, sortAndDirection = state.sortAndDirection,
@ -993,12 +1132,12 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos) positionCallback?.invoke(columns, pos)
}, },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!, onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem, focusedItem = focusedItem,
cardContent = gridCardContent,
) )
} else { } else {
CollectionFolderList( scope.CollectionFolderList(
preferences = preferences, preferences = preferences,
collectionType = item?.data?.collectionType, collectionType = item?.data?.collectionType,
initialPosition = savedPosition, initialPosition = savedPosition,
@ -1014,9 +1153,9 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos) positionCallback?.invoke(columns, pos)
}, },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!, onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem, focusedItem = focusedItem,
itemContent = listItemContent,
) )
} }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
@ -1047,6 +1186,7 @@ fun CollectionFolderViewContent(
ViewOptionsDialog( ViewOptionsDialog(
viewOptions = state.viewOptions, viewOptions = state.viewOptions,
defaultViewOptions = defaultViewOptions, defaultViewOptions = defaultViewOptions,
showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST,
onDismissRequest = { onDismissRequest = {
showViewOptions = false showViewOptions = false
viewActions.saveViewOptions(state.viewOptions) viewActions.saveViewOptions(state.viewOptions)
@ -1055,3 +1195,13 @@ fun CollectionFolderViewContent(
) )
} }
} }
/**
* Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
*/
interface CollectionFolderViewScope {
val viewOptions: ViewOptions
val collectionType: CollectionType?
val sortAndDirection: SortAndDirection
val collectionSize: Int
}

View file

@ -209,7 +209,12 @@ fun ContextMenu(
favorite = item.favorite, favorite = item.favorite,
seriesId = item.data.seriesId, seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, canClearChosenStreams =
chosenStreams.let {
it?.itemPlayback != null ||
it?.audioStc?.isNotEmpty() == true ||
it?.subtitleStc?.isNotEmpty() == true
},
contextMenu = contextMenu, contextMenu = contextMenu,
actions = actions, actions = actions,
onChooseVersion = { onChooseVersion = {
@ -235,11 +240,11 @@ fun ContextMenu(
resources = resources, resources = resources,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
currentIndex = streamChoice =
if (type == MediaStreamType.AUDIO) { if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index chosenStreams?.audioStream
} else { } else {
chosenStreams?.subtitleStream?.index chosenStreams?.subtitleStream
}, },
onClick = { trackIndex -> onClick = { trackIndex ->
actions.onChooseTracks.invoke( actions.onChooseTracks.invoke(

View file

@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -230,28 +231,35 @@ fun DialogPopup(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = properties, properties = properties,
) { ) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
DialogPopupContent( DialogPopupContent(
title = title, title = title,
dialogItems = dialogItems, dialogItems = dialogItems,
waiting = waiting, waiting = waiting,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick, dismissOnClick = dismissOnClick,
elevation = elevation,
modifier = modifier =
Modifier.onKeyEvent { event -> Modifier
val code = event.nativeKeyEvent.keyCode .onKeyEvent { event ->
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && val code = event.nativeKeyEvent.keyCode
code in if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
setOf( code in
KeyEvent.KEYCODE_ENTER, setOf(
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_DPAD_CENTER,
) KeyEvent.KEYCODE_NUMPAD_ENTER,
) { )
waiting = false ) {
} waiting = false
false }
}, false
}.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
) )
} }
} }
@ -265,20 +273,10 @@ fun DialogPopupContent(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dismissOnClick: Boolean = true, dismissOnClick: Boolean = true,
elevation: Dp = 8.dp,
) { ) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier = modifier,
modifier
.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
) { ) {
Text( Text(
text = title, text = title,
@ -452,12 +450,14 @@ fun ConfirmDialog(
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 1.dp, elevation: Dp = 1.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface, bodyColor: Color = MaterialTheme.colorScheme.onSurface,
@StringRes cancelStringRes: Int = R.string.cancel,
@StringRes confirmStringRes: Int = R.string.confirm,
) = BasicDialog( ) = BasicDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
properties = properties, properties = properties,
elevation = elevation, elevation = elevation,
content = { content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
}, },
) )
@ -472,6 +472,8 @@ fun ConfirmDialogContent(
onConfirm: () -> Unit, onConfirm: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface, bodyColor: Color = MaterialTheme.colorScheme.onSurface,
@StringRes cancelStringRes: Int = R.string.cancel,
@StringRes confirmStringRes: Int = R.string.confirm,
) { ) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -502,11 +504,11 @@ fun ConfirmDialogContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
TextButton( TextButton(
stringRes = R.string.cancel, stringRes = cancelStringRes,
onClick = onCancel, onClick = onCancel,
) )
TextButton( TextButton(
stringRes = R.string.confirm, stringRes = confirmStringRes,
onClick = onConfirm, onClick = onConfirm,
) )
} }
@ -651,12 +653,13 @@ fun resourceFor(type: MediaStreamType): Int =
fun chooseStream( fun chooseStream(
resources: Resources, resources: Resources,
streams: List<MediaStream>, streams: List<MediaStream>,
currentIndex: Int?, streamChoice: StreamChoiceResult?,
type: MediaStreamType, type: MediaStreamType,
preferredSubtitleLanguage: String?, preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams { ): DialogParams {
val filteredStreams = streams.filter { it.type == type } val filteredStreams = streams.filter { it.type == type }
val currentIndex = streamChoice?.stream?.index
return DialogParams( return DialogParams(
fromLongClick = false, fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
@ -674,6 +677,16 @@ fun chooseStream(
}, },
supportingContent = { supportingContent = {
}, },
trailingContent =
if (currentIndex == null && streamChoice != null) {
{
Text(
text = streamChoice.reason.description.getString(),
)
}
} else {
null
},
onClick = { onClick.invoke(TrackIndex.DISABLED) }, onClick = { onClick.invoke(TrackIndex.DISABLED) },
), ),
) )
@ -719,6 +732,16 @@ fun chooseStream(
supportingContent = { supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
}, },
trailingContent =
if (currentIndex == stream.index) {
{
Text(
text = streamChoice.reason.description.getString(),
)
}
} else {
null
},
onClick = { onClick.invoke(stream.index) }, onClick = { onClick.invoke(stream.index) },
) )
}, },

View file

@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) =
), ),
), ),
RecommendedRow( RecommendedRow(
title = R.string.top_unwatched, title = R.string.top_rated,
handler = GetItemsRequestHandler, handler = GetItemsRequestHandler,
request = request =
GetItemsRequest( GetItemsRequest(
@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) =
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true, recursive = true,
enableUserData = true, enableUserData = true,
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING), sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING), sortOrder = listOf(SortOrder.DESCENDING),
enableTotalRecordCount = false, enableTotalRecordCount = false,

View file

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

View file

@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? {
item.data.seriesId != null && item.data.seriesId != null &&
item.data.parentLogoImageTag != null item.data.parentLogoImageTag != null
) { ) {
imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) imageUrlService.getItemImageUrl(
itemId = item.data.seriesId!!,
imageType = ImageType.LOGO,
tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
)
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
imageUrlService.getItemImageUrl(item, ImageType.LOGO) imageUrlService.getItemImageUrl(item, ImageType.LOGO)
} else { } else {

View file

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

View file

@ -55,6 +55,7 @@ fun ViewOptionsDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onViewOptionsChange: (ViewOptions) -> Unit, onViewOptionsChange: (ViewOptions) -> Unit,
defaultViewOptions: ViewOptions = ViewOptions(), defaultViewOptions: ViewOptions = ViewOptions(),
showArtistSource: Boolean = false,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@ -72,11 +73,11 @@ fun ViewOptionsDialog(
window.setDimAmount(0f) window.setDimAmount(0f)
} }
val options = val options =
remember(viewOptions.type) { remember(viewOptions.type, showArtistSource) {
when (viewOptions.type) { when (viewOptions.type) {
ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource)
ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource)
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource)
} }
} }
Column( Column(
@ -130,6 +131,12 @@ fun ViewOptionsDialog(
} }
} }
/** Which Jellyfin artists list backs the Music > Artists tab */
enum class ArtistSource {
ARTIST,
ALBUM_ARTIST,
}
/** /**
* Stores the customizable view changes from the user * Stores the customizable view changes from the user
* *
@ -146,6 +153,7 @@ data class ViewOptions(
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true, val showTitles: Boolean = true,
val type: ViewOptionsType = ViewOptionsType.GRID, val type: ViewOptionsType = ViewOptionsType.GRID,
val extras: ViewOptionsExtras = ViewOptionsExtras.None,
) { ) {
companion object { companion object {
val ViewOptionsColumns = val ViewOptionsColumns =
@ -250,12 +258,34 @@ data class ViewOptions(
valueToIndex = { it.ordinal }, valueToIndex = { it.ordinal },
) )
val ViewOptionsArtistSource =
AppChoicePreference<ViewOptions, ArtistSource>(
title = R.string.artist_source,
defaultValue = ArtistSource.ARTIST,
displayValues = R.array.artist_sources,
getter = { it.artistSource },
setter = { viewOptions, value ->
viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value))
},
indexToValue = { ArtistSource.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsReset = val ViewOptionsReset =
AppClickablePreference<ViewOptions>( AppClickablePreference<ViewOptions>(
title = R.string.reset, title = R.string.reset,
) )
val GRID_OPTIONS = /**
* Get the options for a grid [ViewOptions]
*/
fun buildGridOptions(showArtistSource: Boolean) =
buildList {
if (showArtistSource) add(ViewOptionsArtistSource)
addAll(GRID_OPTIONS)
}
private val GRID_OPTIONS =
listOf( listOf(
ViewOptionsTypePref, ViewOptionsTypePref,
ViewOptionsImageType, ViewOptionsImageType,
@ -269,7 +299,16 @@ data class ViewOptions(
ViewOptionsReset, ViewOptionsReset,
) )
val LIST_OPTIONS = /**
* Get the options for a list [ViewOptions]
*/
fun buildListOptions(showArtistSource: Boolean) =
buildList {
if (showArtistSource) add(ViewOptionsArtistSource)
addAll(LIST_OPTIONS)
}
private val LIST_OPTIONS =
listOf( listOf(
ViewOptionsTypePref, ViewOptionsTypePref,
ViewOptionsDetailHeader, ViewOptionsDetailHeader,
@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions
else -> ViewOptionsPoster else -> ViewOptionsPoster
} }
/**
* Extras [ViewOptions] info that is context or library specific
*/
@Serializable
sealed interface ViewOptionsExtras {
@Serializable
data object None : ViewOptionsExtras
@Serializable
data class MusicLibrary(
val artistSource: ArtistSource = ArtistSource.ARTIST,
) : ViewOptionsExtras
}
/**
* The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary]
*/
val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST

View file

@ -9,11 +9,16 @@ import android.media.AudioManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.view.Display import android.view.Display
import android.view.Display.HdrCapabilities
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalGridApi
import androidx.compose.foundation.layout.Grid
import androidx.compose.foundation.layout.GridFlow
import androidx.compose.foundation.layout.GridTrackSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -40,9 +46,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -56,9 +64,11 @@ import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong import org.acra.util.versionCodeLong
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.Date import java.util.Date
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -68,17 +78,18 @@ class DebugViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao, val itemPlaybackDao: ItemPlaybackDao,
val seriesTrackChoiceDao: SeriesTrackChoiceDao,
val clientInfo: ClientInfo, val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo, val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService,
) : ViewModel() { ) : ViewModel() {
val state = MutableStateFlow(DebugState()) val state = MutableStateFlow(DebugState())
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList()) val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList()) val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
val supportedModes by lazy { val display: Display by lazy {
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) displayManager.getDisplay(Display.DEFAULT_DISPLAY)
display.supportedModes.orEmpty()
} }
val av1Included by lazy { val av1Included by lazy {
@ -159,7 +170,7 @@ class DebugViewModel
state.update { state.update {
it.copy( it.copy(
deviceInfo = deviceInfoList, deviceInfo = deviceInfoList,
displayModes = supportedModes.map { it.toString() }, displayModes = display.supportedModes.map { it.toDisplayMode(display) },
) )
} }
} }
@ -169,6 +180,13 @@ class DebugViewModel
itemPlaybacks.value = results itemPlaybacks.value = results
} }
} }
viewModelScope.launchIO {
serverRepository.currentUser?.rowId?.let {
val results = seriesTrackChoiceDao.getAll(it)
Timber.v("Got %s SeriesTrackChoice", results.size)
results.forEach { Timber.v("%s", it) }
}
}
viewModelScope.launchIO { viewModelScope.launchIO {
val logcat = getLogCatLines() val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat this@DebugViewModel.logcat.value = logcat
@ -206,6 +224,20 @@ class DebugViewModel
audioManager.registerAudioDeviceCallback(callback, null) audioManager.registerAudioDeviceCallback(callback, null)
addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
} }
viewModelScope.launchIO {
val hdrSupport =
buildMap {
put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10())
put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus())
put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision())
put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL())
put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8())
put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10())
put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus())
put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision())
}
state.update { it.copy(hdrSupport = hdrSupport) }
}
} }
companion object { companion object {
@ -262,8 +294,9 @@ data class LogcatLine(
data class DebugState( data class DebugState(
val appInfo: List<String> = emptyList(), val appInfo: List<String> = emptyList(),
val deviceInfo: List<String> = emptyList(), val deviceInfo: List<String> = emptyList(),
val displayModes: List<String> = emptyList(), val displayModes: List<DisplayMode> = emptyList(),
val audioInfo: List<String> = emptyList(), val audioInfo: List<String> = emptyList(),
val hdrSupport: Map<String, Boolean> = emptyMap(),
) )
val AudioDeviceInfo.details: String val AudioDeviceInfo.details: String
@ -383,6 +416,7 @@ val AudioDeviceInfo.details: String
"productName=$productName, address=$addressStr)" "productName=$productName, address=$addressStr)"
} }
@OptIn(ExperimentalGridApi::class)
@Composable @Composable
fun DebugPage( fun DebugPage(
preferences: UserPreferences, preferences: UserPreferences,
@ -466,15 +500,54 @@ fun DebugPage(
BodyText(it) BodyText(it)
} }
SubSectionTitle("Display Modes")
state.displayModes.forEach {
BodyText(it)
}
SubSectionTitle("Audio Devices") SubSectionTitle("Audio Devices")
state.audioInfo.forEach { state.audioInfo.forEach {
BodyText(it) BodyText(it)
} }
SubSectionTitle("Display Modes")
Grid(
config = {
repeat(6) { column(GridTrackSize.Auto) }
flow = GridFlow.Row
columnGap(8.dp)
rowGap(8.dp)
},
) {
EmphasizedBodyText("ID")
EmphasizedBodyText("Width")
EmphasizedBodyText("Height")
EmphasizedBodyText("FPS")
EmphasizedBodyText("HDR")
EmphasizedBodyText("Alt FPS")
state.displayModes.forEachIndexed { index, mode ->
BodyText(mode.id.toString())
BodyText(mode.width.toString())
BodyText(mode.height.toString())
BodyText(mode.fps)
BodyText(mode.hdr)
BodyText(mode.altFps)
}
}
SubSectionTitle("Detected HDR Support")
Grid(
config = {
column(GridTrackSize.Auto)
column(GridTrackSize.Auto)
flow = GridFlow.Row
columnGap(8.dp)
rowGap(4.dp)
},
) {
EmphasizedBodyText("HDR Format")
EmphasizedBodyText("Supported")
state.hdrSupport.forEach { (name, supported) ->
BodyText(name)
BodyText(supported.toString())
}
}
} }
} }
item { item {
@ -565,11 +638,29 @@ private fun SubSectionTitle(text: String) {
} }
@Composable @Composable
private fun BodyText(text: String) { private fun BodyText(
text: String,
modifier: Modifier = Modifier,
) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = modifier,
)
}
@Composable
private fun EmphasizedBodyText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier = modifier,
) )
} }
@ -601,3 +692,45 @@ private val THIRD_PARTY_TAGS =
"HardwareConfig:W", "HardwareConfig:W",
"DfltImageHeaderParser:W", "DfltImageHeaderParser:W",
) )
data class DisplayMode(
val id: Int,
val width: Int,
val height: Int,
val fps: String,
val hdr: String,
val altFps: String,
)
fun Display.Mode.toDisplayMode(display: Display): DisplayMode {
val fps = String.format(Locale.getDefault(), "%.3f", refreshRate)
val altFps =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
alternativeRefreshRates.joinToString(", ") {
String.format(Locale.getDefault(), "%.3f", it)
}
} else {
"N/A"
}
val hdr =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) }
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) }
?: "None found"
} else {
"N/A"
}
return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps)
}
fun hdrTypeToString(type: Int): String =
when (type) {
HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+"
HdrCapabilities.HDR_TYPE_HLG -> "HLG"
HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+"
HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi"
HdrCapabilities.HDR_TYPE_INVALID -> "Invalid"
else -> "Unknown ($type)"
}

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.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
@ -700,11 +701,25 @@ class FavoritesViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
collectionStateFor(type)?.let { collectionState -> collectionStateFor(type)?.let { collectionState ->
val random = val list =
(collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() collectionState.items.successValue as? BlockingList<BaseItem?>
Timber.d("Got random item: %s", random?.id) if (list != null) {
random?.destination()?.let { goToRandom(
navigateTo(random.destination()) navigateTo = ::navigateTo,
parentId = libraryDisplayItemId(type),
filter =
collectionState.filter.copy(
includeItemTypes = listOf(type),
),
sortAndDirection = collectionState.sortAndDirection,
recursive = true,
list = list,
)
} else {
Timber.w(
"state.value.items is not successful: %s",
collectionState.items,
)
} }
} }
} catch (ex: CancellationException) { } catch (ex: CancellationException) {

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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -75,7 +74,7 @@ class HomeRowGridViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService, private val navDrawerService: NavDrawerService,
private val homeSettingsService: HomeSettingsService, private val homeDataService: HomeDataService,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
@ -128,7 +127,7 @@ class HomeRowGridViewModel
val libraries = val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val result = val result =
homeSettingsService.fetchDataForRow( homeDataService.fetchDataForRow(
row = rowConfig, row = rowConfig,
scope = viewModelScope, scope = viewModelScope,
prefs = prefs, prefs = prefs,

View file

@ -257,6 +257,7 @@ fun PersonPage(
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
itemId = person.id, itemId = person.id,
imageType = ImageType.PRIMARY, imageType = ImageType.PRIMARY,
tag = person.data.imageTags?.get(ImageType.PRIMARY),
) )
} }
// A long filmography is hard to navigate as a row, so it is cut at the same // A long filmography is hard to navigate as a row, so it is cut at the same

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.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.data.model.canRequestMovies
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
@ -84,14 +83,12 @@ fun DiscoverMovieDetails(
} }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val requestEnabled by viewModel.requestEnabled.collectAsState(false)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showRequestDialog by remember { mutableStateOf(false) } var showRequestDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val st = state.movie) { when (val st = state.movie) {
is DataLoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(st, modifier) ErrorMessage(st, modifier)
@ -153,6 +150,7 @@ fun DiscoverMovieDetails(
RequestMovieDialog( RequestMovieDialog(
loading = state.profileLoading, loading = state.profileLoading,
data = state.requestData, data = state.requestData,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
movie = movie, movie = movie,
onSubmit = { onSubmit = {
@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent(
} }
} }
}, },
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), canRequest = userConfig.canRequestMovies,
canCancel = canCancel, canCancel = canCancel,
trailers = trailers, trailers = trailers,
trailerOnClick = trailerOnClick, trailerOnClick = trailerOnClick,

View file

@ -75,6 +75,8 @@ class DiscoverMovieViewModel
val state: StateFlow<DiscoverMovieState> = _state val state: StateFlow<DiscoverMovieState> = _state
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val requestEnabled =
seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
val request4kEnabled = val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
@ -194,7 +196,7 @@ class DiscoverMovieViewModel
return@launchIO return@launchIO
} }
try { try {
seerrService.api.requestApi.requestPost( val postRequest =
RequestPostRequest( RequestPostRequest(
is4k = request.is4k, is4k = request.is4k,
mediaId = request.movieId, mediaId = request.movieId,
@ -208,8 +210,9 @@ class DiscoverMovieViewModel
profileId = request.profileId, profileId = request.profileId,
rootFolder = request.folder, rootFolder = request.folder,
tags = emptyList(), tags = emptyList(),
), )
) Timber.v("postRequest=%s", postRequest)
seerrService.api.requestApi.requestPost(postRequest)
} catch (ex: CancellationException) { } catch (ex: CancellationException) {
throw ex throw ex
} catch (ex: Exception) { } catch (ex: Exception) {

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.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.data.model.canRequestTv
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
@ -88,6 +87,7 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current val resources = LocalResources.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val requestEnabled by viewModel.requestEnabled.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState() val request4kEnabled by viewModel.request4kEnabled.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -112,6 +112,7 @@ fun DiscoverSeriesDetails(
DiscoverSeriesDetailsContent( DiscoverSeriesDetailsContent(
preferences = preferences, preferences = preferences,
series = item, series = item,
availability = state.availability,
userConfig = userConfig, userConfig = userConfig,
rating = state.rating, rating = state.rating,
canCancel = state.canCancelRequest, canCancel = state.canCancelRequest,
@ -188,6 +189,7 @@ fun DiscoverSeriesDetails(
title = state.tvSeries.successValue?.name ?: "", title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons, seasons = state.seasons,
seasons4k = state.seasons4k, seasons4k = state.seasons4k,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = { onSubmit = {
showRequestSeasonDialog = false showRequestSeasonDialog = false
@ -212,6 +214,7 @@ fun DiscoverSeriesDetailsContent(
preferences: UserPreferences, preferences: UserPreferences,
userConfig: SeerrUserConfig?, userConfig: SeerrUserConfig?,
series: TvDetails, series: TvDetails,
availability: SeerrAvailability,
rating: SourceRatings?, rating: SourceRatings?,
canCancel: Boolean, canCancel: Boolean,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
@ -275,9 +278,7 @@ fun DiscoverSeriesDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp), .padding(top = HeaderUtils.topPadding, bottom = 16.dp),
) )
ExpandableDiscoverButtons( ExpandableDiscoverButtons(
availability = availability = availability,
SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick, requestOnClick = requestOnClick,
pendingOnClick = requestOnClick, pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick, cancelOnClick = cancelOnClick,
@ -291,7 +292,7 @@ fun DiscoverSeriesDetailsContent(
} }
} }
}, },
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), canRequest = userConfig.canRequestTv,
canCancel = canCancel, canCancel = canCancel,
trailers = trailers, trailers = trailers,
trailerOnClick = trailerOnClick, trailerOnClick = trailerOnClick,

View file

@ -75,6 +75,10 @@ class DiscoverSeriesViewModel
val state: StateFlow<DiscoverSeriesState> = _state val state: StateFlow<DiscoverSeriesState> = _state
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val requestEnabled =
seerrServerRepository.current
.map { it?.requestTvEnabled ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val request4kEnabled = val request4kEnabled =
seerrServerRepository.current seerrServerRepository.current
.map { it?.request4kTvEnabled ?: false } .map { it?.request4kTvEnabled ?: false }
@ -108,7 +112,7 @@ class DiscoverSeriesViewModel
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateSeasonStatus(tv) updateSeasonStatus(tv)
updateCanCancel() updateAvailability()
viewModelScope.launchIO { viewModelScope.launchIO {
val rating = val rating =
@ -190,6 +194,7 @@ class DiscoverSeriesViewModel
tv: TvDetails, tv: TvDetails,
is4k: Boolean, is4k: Boolean,
) { ) {
Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
val currentUserId = seerrServerRepository.currentUserId.first() val currentUserId = seerrServerRepository.currentUserId.first()
val seasonStatus = mutableMapOf<Int, RequestStatus>() val seasonStatus = mutableMapOf<Int, RequestStatus>()
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>() val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
@ -230,7 +235,10 @@ class DiscoverSeriesViewModel
seasonStatus[season.seasonNumber] = new seasonStatus[season.seasonNumber] = new
} }
editable[season.seasonNumber] = editable[season.seasonNumber] =
currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status currentUserId == req.requestedBy?.id && (
req.status == RequestStatus.PENDING.status ||
req.status == RequestStatus.DECLINED.status
)
} }
} }
} }
@ -299,7 +307,7 @@ class DiscoverSeriesViewModel
} }
} }
private suspend fun updateCanCancel() { private suspend fun updateAvailability() {
val user = userConfig.firstOrNull() val user = userConfig.firstOrNull()
val canCancel = val canCancel =
canUserCancelRequest( canUserCancelRequest(
@ -308,7 +316,32 @@ class DiscoverSeriesViewModel
?.mediaInfo ?.mediaInfo
?.requests, ?.requests,
) )
_state.update { it.copy(canCancelRequest = canCancel) } val status =
state.value.tvSeries.successValue?.let { tv ->
val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
if (overallStatus == SeerrAvailability.UNKNOWN) {
val userRequests =
tv.mediaInfo
?.requests
?.filter { it.requestedBy?.id == user?.id }
.orEmpty()
if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
SeerrAvailability.PENDING
} else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
SeerrAvailability.PROCESSING
} else {
SeerrAvailability.UNKNOWN
}
} else {
overallStatus
}
}
_state.update {
it.copy(
canCancelRequest = canCancel,
availability = status ?: SeerrAvailability.UNKNOWN,
)
}
} }
fun requestOnClick() { fun requestOnClick() {
@ -391,7 +424,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let { fetchAndSetItem().await()?.let {
updateSeasonStatus(it) updateSeasonStatus(it)
updateCanCancel() updateAvailability()
} }
} }
} }
@ -412,7 +445,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let { fetchAndSetItem().await()?.let {
updateSeasonStatus(it) updateSeasonStatus(it)
updateCanCancel() updateAvailability()
} }
} }
} }
@ -431,4 +464,5 @@ data class DiscoverSeriesState(
val canCancelRequest: Boolean = false, val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending, val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(), val requestData: SeerrRequestData = SeerrRequestData(),
val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
) )

View file

@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun RequestMovieDialog( fun RequestMovieDialog(
loading: LoadingState, loading: LoadingState,
data: SeerrRequestData, data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean, request4kEnabled: Boolean,
movie: MovieDetails, movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit, onSubmit: (MovieRequest) -> Unit,
@ -54,10 +55,11 @@ fun RequestMovieDialog(
RequestMovie( RequestMovie(
loading = loading, loading = loading,
data = data, data = data,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
movie = movie, movie = movie,
onSubmit = onSubmit, onSubmit = onSubmit,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(24.dp),
) )
} }
} }
@ -66,6 +68,7 @@ fun RequestMovieDialog(
fun RequestMovie( fun RequestMovie(
loading: LoadingState, loading: LoadingState,
data: SeerrRequestData, data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean, request4kEnabled: Boolean,
movie: MovieDetails, movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit, onSubmit: (MovieRequest) -> Unit,
@ -90,7 +93,8 @@ fun RequestMovie(
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
) { ) {
if (request4kEnabled) { // Both enabled, show choice
if (requestEnabled && request4kEnabled) {
val items = val items =
remember { remember {
listOf( listOf(
@ -118,12 +122,13 @@ fun RequestMovie(
) )
} else { } else {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
} }
} }
} else { } else {
RequestMovieWithOptions( RequestMovieWithOptions(
data = data, data = data,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
movie = movie, movie = movie,
onSubmit = onSubmit, onSubmit = onSubmit,
@ -137,12 +142,13 @@ fun RequestMovie(
@Composable @Composable
private fun RequestMovieWithOptions( private fun RequestMovieWithOptions(
data: SeerrRequestData, data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean, request4kEnabled: Boolean,
movie: MovieDetails, movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit, onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var is4k by remember { mutableStateOf(request4kEnabled) } var is4k by remember { mutableStateOf(!requestEnabled) }
var profile by remember(is4k) { var profile by remember(is4k) {
mutableStateOf( mutableStateOf(
if (is4k) { if (is4k) {
@ -185,7 +191,7 @@ private fun RequestMovieWithOptions(
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 8.dp), .padding(bottom = 8.dp),
) { ) {
if (request4kEnabled) { if (requestEnabled && request4kEnabled) {
ClickSwitch( ClickSwitch(
label = stringResource(R.string.request_4k), label = stringResource(R.string.request_4k),
checked = is4k, checked = is4k,
@ -377,7 +383,7 @@ fun MovieRequestPreview() {
), ),
profiles4k = profiles4k =
listOf( listOf(
SeerrProfile(1, "HD", true), SeerrProfile(1, "4K", true),
SeerrProfile(2, "Ultra HD", false), SeerrProfile(2, "Ultra HD", false),
), ),
rootFolders4k = rootFolders4k =
@ -385,7 +391,8 @@ fun MovieRequestPreview() {
SeerrRootFolder(1, "/movies", "400GB", true), SeerrRootFolder(1, "/movies", "400GB", true),
), ),
), ),
request4kEnabled = false, requestEnabled = true,
request4kEnabled = true,
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
onSubmit = {}, onSubmit = {},
modifier = modifier =

View file

@ -65,11 +65,12 @@ fun RequestSeasons(
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>, seasons4k: List<RequestSeason>,
data: SeerrRequestData, data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean, request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit, onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var is4k by remember { mutableStateOf(request4kEnabled) } var is4k by remember { mutableStateOf(!requestEnabled) }
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
val allSeasonNumbers = val allSeasonNumbers =
@ -161,7 +162,7 @@ fun RequestSeasons(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
if (request4kEnabled) { if (requestEnabled && request4kEnabled) {
ClickSwitch( ClickSwitch(
label = stringResource(R.string.request_4k), label = stringResource(R.string.request_4k),
checked = is4k, checked = is4k,
@ -391,6 +392,7 @@ fun RequestSeasonsDialog(
data: SeerrRequestData, data: SeerrRequestData,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>, seasons4k: List<RequestSeason>,
requestEnabled: Boolean,
request4kEnabled: Boolean, request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit, onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -418,6 +420,7 @@ fun RequestSeasonsDialog(
data = data, data = data,
seasons = seasons, seasons = seasons,
seasons4k = seasons4k, seasons4k = seasons4k,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = onSubmit, onSubmit = onSubmit,
modifier = modifier =
@ -475,6 +478,7 @@ fun RequestSeasonsPreview() {
SeerrRootFolder(1, "/tv", "400GB", true), SeerrRootFolder(1, "/tv", "400GB", true),
), ),
), ),
requestEnabled = true,
request4kEnabled = true, request4kEnabled = true,
onSubmit = { }, onSubmit = { },
modifier = Modifier.width(400.dp), modifier = Modifier.width(400.dp),

View file

@ -145,11 +145,10 @@ class EpisodeViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
} }
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
@ -163,7 +162,6 @@ class EpisodeViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -171,10 +169,7 @@ class EpisodeViewModel
trackIndex = trackIndex, trackIndex = trackIndex,
type = type, type = type,
) )
val chosen = val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
} }

View file

@ -149,7 +149,11 @@ class LiveTvViewModel
number = it.channelNumber, number = it.channelNumber,
name = it.channelName ?: it.name, name = it.channelName ?: it.name,
imageUrl = imageUrl =
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY), imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.PRIMARY,
tag = it.imageTags?.get(ImageType.PRIMARY),
),
favorite = it.userData?.isFavorite == true, favorite = it.userData?.isFavorite == true,
) )
} }
@ -299,6 +303,7 @@ class LiveTvViewModel
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
dto.id, dto.id,
ImageType.PRIMARY, ImageType.PRIMARY,
tag = dto.imageTags?.get(ImageType.PRIMARY),
), ),
) )
if (index == 0) { if (index == 0) {

View file

@ -223,11 +223,10 @@ class MovieViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
} }
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
@ -241,7 +240,6 @@ class MovieViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -249,10 +247,7 @@ class MovieViewModel
trackIndex = trackIndex, trackIndex = trackIndex,
type = type, type = type,
) )
val chosen = val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
} }

View file

@ -561,11 +561,10 @@ class SeriesViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen = val chosen =
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
} }
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
@ -579,7 +578,6 @@ class SeriesViewModel
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = val result =
itemPlaybackRepository.saveTrackSelection( itemPlaybackRepository.saveTrackSelection(
item = item, item = item,
@ -587,10 +585,7 @@ class SeriesViewModel
trackIndex = trackIndex, trackIndex = trackIndex,
type = type, type = type,
) )
val chosen = val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
_state.update { it.copy(chosenStreams = chosen) } _state.update { it.copy(chosenStreams = chosen) }
} }
} }

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.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
@ -62,6 +63,7 @@ class HomeViewModel
val serverReportService: ServerReportService, val serverReportService: ServerReportService,
private val navDrawerService: NavDrawerService, private val navDrawerService: NavDrawerService,
private val homeSettingsService: HomeSettingsService, private val homeSettingsService: HomeSettingsService,
private val homeDataService: HomeDataService,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
@ -186,7 +188,7 @@ class HomeViewModel
semaphore.withPermit { semaphore.withPermit {
Timber.v("Fetching row: %s", row) Timber.v("Fetching row: %s", row)
try { try {
homeSettingsService.fetchDataForRow( homeDataService.fetchDataForRow(
row = row.config, row = row.config,
scope = viewModelScope, scope = viewModelScope,
prefs = prefs, prefs = prefs,

View file

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

View file

@ -31,10 +31,6 @@ fun HomeSettingsGlobal(
preferences: AppPreferences, preferences: AppPreferences,
onPreferenceChange: (AppPreferences) -> Unit, onPreferenceChange: (AppPreferences) -> Unit,
onClickResize: (Int) -> Unit, onClickResize: (Int) -> Unit,
onClickSave: () -> Unit,
onClickLoad: () -> Unit,
onClickLoadWeb: () -> Unit,
onClickReset: () -> Unit,
onClickViewNextUp: () -> Unit, onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -146,62 +142,6 @@ fun HomeSettingsGlobal(
modifier = Modifier, modifier = Modifier,
) )
} }
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.save_to_server),
leadingContent = {
Text(
text = stringResource(R.string.fa_cloud_arrow_up),
fontFamily = FontAwesome,
)
},
onClick = onClickSave,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.load_from_server),
leadingContent = {
Text(
text = stringResource(R.string.fa_cloud_arrow_down),
fontFamily = FontAwesome,
)
},
onClick = onClickLoad,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.load_from_web_client),
leadingContent = {
Text(
text = stringResource(R.string.fa_download),
fontFamily = FontAwesome,
)
},
onClick = onClickLoadWeb,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.reset),
leadingContent = {
Text(
text = stringResource(R.string.fa_arrows_rotate),
fontFamily = FontAwesome,
)
},
onClick = onClickReset,
modifier = Modifier,
)
}
} }
} }
} }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.main.settings package com.github.damontecres.wholphin.ui.main.settings
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -64,11 +65,14 @@ fun HomeSettingsPage(
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) } var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) } var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) } var showRemovedNextUpDialog by remember { mutableStateOf(false) }
var showSaveDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val serverPluginActive by viewModel.serverPluginActive.collectAsState()
var position by rememberPosition(0, 0) var position by rememberPosition(0, 0)
// TODO discover rows // TODO discover rows
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
// Adds a row, waits until its done loading, then scrolls to the new row // Adds a row, waits until its done loading, then scrolls to the new row
fun addRow( fun addRow(
@ -86,6 +90,10 @@ fun HomeSettingsPage(
} }
} }
BackHandler(settingsHaveChanged) {
showSaveDialog = true
}
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
@ -137,6 +145,7 @@ fun HomeSettingsPage(
position = RowColumn(index, 0) position = RowColumn(index, 0)
} }
}, },
onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
modifier = destModifier, modifier = destModifier,
) )
} }
@ -282,6 +291,16 @@ fun HomeSettingsPage(
} }
}, },
onClickResize = { viewModel.resizeCards(it) }, onClickResize = { viewModel.resizeCards(it) },
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier,
)
}
HomeSettingsDestination.SourceSettings -> {
HomeSettingsSourcePage(
source = state.source,
onClickSave = { onClickSave = {
showConfirmDialog = showConfirmDialog =
ShowConfirm(R.string.overwrite_server_settings) { ShowConfirm(R.string.overwrite_server_settings) {
@ -294,6 +313,13 @@ fun HomeSettingsPage(
viewModel.loadFromRemote() viewModel.loadFromRemote()
} }
}, },
serverPluginActive = serverPluginActive,
onClickLoadPlugin = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
viewModel.loadFromRemotePlugin()
}
},
onClickLoadWeb = { onClickLoadWeb = {
showConfirmDialog = showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) { ShowConfirm(R.string.overwrite_local_settings) {
@ -306,9 +332,6 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() } addRow(false) { viewModel.resetToDefault() }
} }
}, },
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier, modifier = destModifier,
) )
} }
@ -375,6 +398,29 @@ fun HomeSettingsPage(
) )
} }
} }
if (showSaveDialog) {
SaveHomeSettingsDialog(
onDismissRequest = { showSaveDialog = false },
onSaveLocal = {
scope.launch {
viewModel.saveToLocal().join()
viewModel.navigationManager.goBack()
}
},
onSaveServer = {
scope.launch {
viewModel.saveToRemote().join()
viewModel.navigationManager.goBack()
}
},
onDiscard = {
scope.launch {
viewModel.discardChanges().join()
viewModel.navigationManager.goBack()
}
},
)
}
} }
data class ShowConfirm( data class ShowConfirm(

View file

@ -49,6 +49,11 @@ enum class MoveDirection {
DOWN, DOWN,
} }
private const val POS_ADD_ROW = 0
private const val POS_SOURCE = POS_ADD_ROW + 1
private const val POS_SETTINGS = POS_SOURCE + 1
private const val POS_PRESETS = POS_SETTINGS + 1
@Composable @Composable
fun HomeSettingsRowList( fun HomeSettingsRowList(
state: HomePageSettingsState, state: HomePageSettingsState,
@ -58,13 +63,14 @@ fun HomeSettingsRowList(
onClickPresets: () -> Unit, onClickPresets: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit, onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit, onClickDelete: (Int) -> Unit,
onClickSourceSettings: () -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val itemsBeforeRows = 4 val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
val focusRequesters = val focusRequesters =
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
@ -80,7 +86,7 @@ fun HomeSettingsRowList(
modifier = modifier =
modifier modifier
.fillMaxHeight() .fillMaxHeight()
.focusRestorer(focusRequesters[0]), .focusRestorer(focusRequesters[POS_ADD_ROW]),
) { ) {
item { item {
HomeSettingsListItem( HomeSettingsListItem(
@ -93,10 +99,30 @@ fun HomeSettingsRowList(
) )
}, },
onClick = { onClick = {
position = 0 position = POS_ADD_ROW
onClickAdd.invoke() onClickAdd.invoke()
}, },
modifier = Modifier.focusRequester(focusRequesters[0]), modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = "Source",
leadingContent = {
Text(
text = stringResource(R.string.fa_download),
fontFamily = FontAwesome,
)
},
supportingContent = {
Text(text = stringResource(state.source.stringResId))
},
onClick = {
position = POS_SOURCE
onClickSourceSettings.invoke()
},
modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
) )
} }
item { item {
@ -110,10 +136,10 @@ fun HomeSettingsRowList(
) )
}, },
onClick = { onClick = {
position = 1 position = POS_SETTINGS
onClickSettings.invoke() onClickSettings.invoke()
}, },
modifier = Modifier.focusRequester(focusRequesters[1]), modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
) )
} }
item { item {
@ -132,10 +158,10 @@ fun HomeSettingsRowList(
) )
}, },
onClick = { onClick = {
position = 2 position = POS_PRESETS
onClickPresets.invoke() onClickPresets.invoke()
}, },
modifier = Modifier.focusRequester(focusRequesters[2]), modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
) )
} }
item { item {

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.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@ -76,37 +81,55 @@ class HomeSettingsViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val homeSettingsService: HomeSettingsService, private val homeSettingsService: HomeSettingsService,
private val homeDataService: HomeDataService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService, private val navDrawerService: NavDrawerService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val serverPluginApi: ServerPluginApi,
val preferencesDataStore: DataStore<AppPreferences>, val preferencesDataStore: DataStore<AppPreferences>,
@param:IoCoroutineScope private val ioScope: CoroutineScope, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state val state: StateFlow<HomePageSettingsState> = _state
val serverPluginActive get() = serverRepository.serverPluginInstalled
val currentUser
get() =
serverRepository.currentUserFlow.filterNotNull().stateIn(
viewModelScope,
SharingStarted.Eagerly,
serverRepository.currentUser!!,
)
val settingsChanged =
state
.map {
HomePageSettings(
rows = it.rows.map { it.config },
version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) != originalSettings
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private var idCounter by Delegates.notNull<Int>() private var idCounter by Delegates.notNull<Int>()
val discoverEnabled = seerrServerRepository.active val discoverEnabled = seerrServerRepository.active
private var originalLocalSettings: HomePageSettings? = null private var originalSettings: HomePageSettings? = null
private var originalRemoteSettings: HomePageSettings? = null
init { init {
addCloseable { saveToLocal() }
viewModelScope.launchIO { viewModelScope.launchIO {
val user = serverRepository.currentUser ?: return@launchIO
val userDto = serverRepository.currentUserDto ?: return@launchIO val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings = val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) originalSettings = currentSettings.asHomePageSettings()
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
Timber.v("currentSettings=%s", currentSettings) Timber.v("currentSettings=%s", currentSettings)
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update { _state.update {
it.copy( it.copy(
source = currentSettings.source,
libraries = libraries, libraries = libraries,
rows = currentSettings.rows, rows = currentSettings.rows,
) )
@ -134,7 +157,7 @@ class HomeSettingsViewModel
viewModelScope.async(WholphinDispatchers.IO) { viewModelScope.async(WholphinDispatchers.IO) {
semaphore.withPermit { semaphore.withPermit {
try { try {
homeSettingsService.fetchDataForRow( homeDataService.fetchDataForRow(
row = row.config, row = row.config,
scope = viewModelScope, scope = viewModelScope,
prefs = prefs, prefs = prefs,
@ -496,7 +519,7 @@ class HomeSettingsViewModel
} }
} }
fun saveToRemote() { fun saveToRemote(): Job =
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser?.let { user -> serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote") Timber.d("Saving home settings to remote")
@ -506,6 +529,10 @@ class HomeSettingsViewModel
try { try {
Timber.d("saveToRemote") Timber.d("saveToRemote")
homeSettingsService.saveToServer(user.id, settings) homeSettingsService.saveToServer(user.id, settings)
homeSettingsService.updateCurrent(
HomePageSettingsSource.SERVER_PROFILE,
settings,
)
showSaveToast() showSaveToast()
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex) Timber.e(ex)
@ -513,7 +540,6 @@ class HomeSettingsViewModel
} }
} }
} }
}
fun loadFromRemote() { fun loadFromRemote() {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -530,7 +556,10 @@ class HomeSettingsViewModel
} }
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update { _state.update {
it.copy(rows = newRows) it.copy(
source = HomePageSettingsSource.SERVER_PROFILE,
rows = newRows,
)
} }
} else { } else {
Timber.v("No remote settings") Timber.v("No remote settings")
@ -560,7 +589,10 @@ class HomeSettingsViewModel
Timber.v("Got web settings") Timber.v("Got web settings")
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update { _state.update {
it.copy(rows = result.rows) it.copy(
source = HomePageSettingsSource.LOCAL,
rows = result.rows,
)
} }
} else { } else {
Timber.v("No web settings") Timber.v("No web settings")
@ -575,34 +607,19 @@ class HomeSettingsViewModel
} }
} }
fun saveToLocal() { fun saveToLocal(): Job =
// This uses injected ioScope so that it will still run when the page is closing viewModelScope.launchIO {
ioScope.launchIO {
serverRepository.currentUser?.let { user -> serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config } val rows = state.value.rows.map { it.config }
val settings = val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try { try {
Timber.d("saveToLocal") Timber.d("saveToLocal")
// Only save if there are changes based on original source
val shouldSave =
if (originalLocalSettings != null) {
originalLocalSettings != settings
} else if (originalRemoteSettings != null) {
originalRemoteSettings != settings
} else {
true
}
if (shouldSave) {
homeSettingsService.saveToLocal(user.id, settings)
homeSettingsService.updateCurrent(user.id, settings)
showSaveToast()
} else {
Timber.d("No changes")
}
} catch (ex: UnsupportedHomeSettingsVersionException) {
Timber.w(ex, "Overwriting local settings")
homeSettingsService.saveToLocal(user.id, settings) homeSettingsService.saveToLocal(user.id, settings)
homeSettingsService.updateCurrent(
HomePageSettingsSource.LOCAL,
settings,
)
showSaveToast() showSaveToast()
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex) Timber.e(ex)
@ -610,17 +627,11 @@ class HomeSettingsViewModel
} }
} }
} }
}
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
_state.update { _state.update {
update.invoke(it) update.invoke(it)
} }
serverRepository.currentUser?.id?.let { userId ->
homeSettingsService.currentSettings.update {
HomePageResolvedSettings(userId, state.value.rows)
}
}
} }
fun resizeCards(relative: Int) { fun resizeCards(relative: Int) {
@ -649,12 +660,32 @@ class HomeSettingsViewModel
fun resetToDefault() = fun resetToDefault() =
viewModelScope.launchIO { viewModelScope.launchIO {
val userId = serverRepository.currentUser?.id ?: return@launchIO val user = serverRepository.currentUser ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) } _state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.createDefault(userId) val result =
if (serverPluginActive.value) {
try {
val settings =
homeSettingsService.fetchSettingsFor(
user.id,
HomePageSettingsSource.PLUGIN,
)
settings
} catch (ex: Exception) {
Timber.e(ex, "Plugin is active, but error fetching home settings")
null
}
} else {
null
} ?: homeSettingsService.createDefault(user.id)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update { _state.update {
it.copy(rows = result.rows, rowData = emptyList()) it.copy(
source = result.source,
rows = result.rows,
rowData = emptyList(),
)
} }
fetchRowData() fetchRowData()
} }
@ -746,6 +777,10 @@ class HomeSettingsViewModel
is HomeRowConfig.TvChannels -> { is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv) it.config.updateViewOptions(preset.liveTv)
} }
is HomeRowConfig.CustomEndpoint -> {
it.config
}
} }
it.copy(config = newConfig) it.copy(config = newConfig)
} }
@ -767,6 +802,43 @@ class HomeSettingsViewModel
} }
} }
fun loadFromRemotePlugin() {
viewModelScope.launchIO {
Timber.d("Loading home settings from server plugin")
try {
// TODO should this remove local settings?
// TODO how to set up priorities
_state.update { it.copy(loading = LoadingState.Loading) }
val result = serverPluginApi.fetchHomePageSettings()
if (result != null) {
Timber.v("Got remote settings")
val newRows =
result.rows.mapIndexed { index, config ->
homeSettingsService.resolve(index, config)
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
source = HomePageSettingsSource.PLUGIN,
rows = newRows,
)
}
} else {
Timber.v("No server plugin settings")
showToast(context, "No server plugin settings found")
}
fetchRowData()
} catch (ex: UnsupportedHomeSettingsVersionException) {
// TODO
Timber.w(ex)
showToast(context, "Error: ${ex.localizedMessage}")
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
fun onConfigAction( fun onConfigAction(
row: HomeRowConfigDisplay, row: HomeRowConfigDisplay,
action: HomeRowConfigAction, action: HomeRowConfigAction,
@ -861,10 +933,13 @@ class HomeSettingsViewModel
fetchRowData() fetchRowData()
} }
} }
fun discardChanges(): Job = Job().apply { complete() }
} }
data class HomePageSettingsState( data class HomePageSettingsState(
val loading: LoadingState, val loading: LoadingState,
val source: HomePageSettingsSource,
val rows: List<HomeRowConfigDisplay>, val rows: List<HomeRowConfigDisplay>,
val rowData: List<HomeRowLoadingState>, val rowData: List<HomeRowLoadingState>,
val libraries: List<Library>, val libraries: List<Library>,
@ -873,6 +948,7 @@ data class HomePageSettingsState(
val EMPTY = val EMPTY =
HomePageSettingsState( HomePageSettingsState(
LoadingState.Pending, LoadingState.Pending,
HomePageSettingsSource.UNSET,
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(), emptyList(),

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 @Serializable
data class Slideshow( data class Slideshow(
val parentId: UUID, val parentId: UUID?,
val index: Int, val index: Int,
val filter: CollectionFolderFilter, val filter: CollectionFolderFilter,
val sortAndDirection: SortAndDirection, val sortAndDirection: SortAndDirection,

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.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem( NavigationDrawerItem(
modifier = modifier, modifier = modifier,
selected = false, selected = false,
@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon(
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Text( Text(
modifier = Modifier, modifier = Modifier.enableMarquee(focused),
text = user.name ?: user.id.toString(), text = user.name ?: user.id.toString(),
maxLines = 1, maxLines = 1,
) )
@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem(
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Text( Text(
modifier = Modifier, modifier = Modifier.enableMarquee(focused),
text = text, text = text,
maxLines = 1, maxLines = 1,
) )
@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem(
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Text( Text(
modifier = Modifier, modifier = Modifier.enableMarquee(focused),
text = library.name(context), text = library.name(context),
maxLines = 1, maxLines = 1,
) )

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.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -158,7 +159,11 @@ class PlayExternalViewModel
is PlaylistItem.Media -> playlistItem.item is PlaylistItem.Media -> playlistItem.item
} }
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
item.data,
SeriesTrackChoiceType.SUBTITLE,
)
if (mediaSource == null) { if (mediaSource == null) {
Timber.w("Media source is null") Timber.w("Media source is null")
return@launchDefault return@launchDefault
@ -168,13 +173,14 @@ class PlayExternalViewModel
val subtitleIndex = val subtitleIndex =
streamChoiceService streamChoiceService
.chooseSubtitleStream( .chooseSubtitleStream(
item = item.data,
source = mediaSource, source = mediaSource,
audioStream = null, audioStream = null,
seriesId = item.data.seriesId,
itemPlayback = playbackConfig, itemPlayback = playbackConfig,
plc = plc, stc = subtitleStc,
prefs = prefs, prefs = prefs,
)?.index )?.stream
?.index
val externalSubtitles = val externalSubtitles =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.isExternal } ?.filter { it.isExternal }
@ -237,6 +243,8 @@ class PlayExternalViewModel
} }
// VLC // VLC
// A zero position alone does not override VLC's saved resume position.
putExtra("from_start", positionMs == 0L)
if (subtitleUrls.isNotEmpty()) { if (subtitleUrls.isNotEmpty()) {
putExtra("subtitles_location", subtitleUrls.first().toString()) putExtra("subtitles_location", subtitleUrls.first().toString())
} }

View file

@ -42,8 +42,10 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
@ -60,6 +62,7 @@ enum class PlaybackDialogType {
PLAYBACK_SPEED, PLAYBACK_SPEED,
VIDEO_SCALE, VIDEO_SCALE,
SUBTITLE_DELAY, SUBTITLE_DELAY,
BITRATE,
} }
data class PlaybackSettings( data class PlaybackSettings(
@ -73,6 +76,7 @@ data class PlaybackSettings(
val subtitleDelay: Duration, val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean, val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: Boolean, val playbackSpeedEnabled: Boolean,
val maxBitrate: Long,
) )
/** /**
@ -162,6 +166,13 @@ fun PlaybackDialog(
), ),
) )
} }
add(
BottomDialogItem(
data = PlaybackDialogType.BITRATE,
headline = stringResource(R.string.max_bitrate),
supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) },
),
)
add( add(
BottomDialogItem( BottomDialogItem(
data = PlaybackDialogType.DEBUG, data = PlaybackDialogType.DEBUG,
@ -271,6 +282,29 @@ fun PlaybackDialog(
} }
} }
} }
PlaybackDialogType.BITRATE -> {
val choices =
AppPreference.bitrateValues.map { bitrate ->
BottomDialogItem(
data = bitrate,
headline = formatMaxBitrate(bitrate),
supporting = null,
)
}
BottomDialog(
choices = choices,
currentChoice = choices.firstOrNull { it.data == settings.maxBitrate },
onDismissRequest = {
onControllerInteraction.invoke()
onDismissRequest.invoke()
},
onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data))
},
gravity = leftGravity,
)
}
} }
} }

View file

@ -322,6 +322,10 @@ fun PlaybackPageContent(
player.seekToPrevious() player.seekToPrevious()
} }
} }
is PlaybackAction.ChangeBitrate -> {
viewModel.changeMaxBitrate(it.maxBitrate)
}
} }
} }
@ -752,6 +756,7 @@ fun PlaybackPageContent(
// TODO Passing through audio prevents changing playback speed // TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164 // See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
maxBitrate = state.maxBitrate,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = playbackDialog =

View file

@ -24,6 +24,7 @@ data class PlaybackState(
val nextUp: BaseItem? = null, val nextUp: BaseItem? = null,
val playlistIndex: Int = 0, val playlistIndex: Int = 0,
val playlist: Playlist = Playlist(emptyList()), val playlist: Playlist = Playlist(emptyList()),
val maxBitrate: Long = -1L,
) { ) {
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
val hasPrevious: Boolean get() = playlistIndex > 0 val hasPrevious: Boolean get() = playlistIndex > 0

View file

@ -5,6 +5,7 @@ import android.media.MediaCodecList
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
@ -198,7 +200,7 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDtoFlow val currentUserDto = serverRepository.currentUserDtoFlow
// Exposed for testing @VisibleForTesting
val initJob: Job val initJob: Job
init { init {
@ -290,8 +292,16 @@ class PlaybackViewModel
*/ */
private suspend fun init() { private suspend fun init() {
musicService.stop() musicService.stop()
_state.update { it.copy(nextUp = null) }
this.preferences = userPreferencesService.getCurrent() this.preferences = userPreferencesService.getCurrent()
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
_state.update {
it.copy(
nextUp = null,
maxBitrate = maxBitrate,
)
}
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() } addCloseable { refreshRateService.resetRefreshRate() }
} }
@ -489,7 +499,16 @@ class PlaybackViewModel
null null
} }
val plc = streamChoiceService.getPlaybackLanguageChoice(base) val audioStc =
streamChoiceService.getSeriesTrackChoices(
base,
SeriesTrackChoiceType.AUDIO,
)
val subtitleStc =
streamChoiceService.getSeriesTrackChoices(
base,
SeriesTrackChoiceType.AUDIO,
)
if (mediaSource == null && !isLiveTv) { if (mediaSource == null && !isLiveTv) {
showToast( showToast(
@ -518,30 +537,31 @@ class PlaybackViewModel
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
val audioStream = val audioResult =
mediaSource?.let { mediaSource?.let {
streamChoiceService streamChoiceService
.chooseAudioStream( .chooseAudioStream(
source = mediaSource, source = mediaSource,
seriesId = base.seriesId, item = base,
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
plc = plc, stc = audioStc,
prefs = preferences, prefs = preferences,
) )
} }
val audioIndex = audioStream?.index val audioIndex = audioResult?.stream?.index
val subtitleIndex = val subtitleIndex =
mediaSource?.let { mediaSource?.let {
streamChoiceService streamChoiceService
.chooseSubtitleStream( .chooseSubtitleStream(
item = base,
source = mediaSource, source = mediaSource,
audioStream = audioStream, audioStream = audioResult?.stream,
seriesId = base.seriesId,
itemPlayback = itemPlayback, itemPlayback = itemPlayback,
plc = plc, stc = subtitleStc,
prefs = preferences, prefs = preferences,
)?.index )?.stream
?.index
} }
Timber.d( Timber.d(
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
@ -616,6 +636,7 @@ class PlaybackViewModel
positionMs: Long = 0, positionMs: Long = 0,
enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
maxBitrate: Long = this.state.value.maxBitrate,
): Unit = ): Unit =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
val itemId = item.id val itemId = item.id
@ -626,7 +647,8 @@ class PlaybackViewModel
state.value.currentPlayback?.let { currentPlayback -> state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback.item.id == item.id && if (currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
enableDirectPlay enableDirectPlay &&
state.value.maxBitrate == maxBitrate
) { ) {
val wasSuccessful = val wasSuccessful =
changeStreamsDirectPlay( changeStreamsDirectPlay(
@ -639,18 +661,17 @@ class PlaybackViewModel
} }
Timber.i( Timber.i(
"changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s",
itemId, itemId,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
enableDirectPlay, enableDirectPlay,
enableDirectStream, enableDirectStream,
positionMs, positionMs,
maxBitrate,
) )
_state.update { it.copy(maxBitrate = maxBitrate) }
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by val response by
api.mediaInfoApi api.mediaInfoApi
.getPostedPlaybackInfo( .getPostedPlaybackInfo(
@ -696,6 +717,7 @@ class PlaybackViewModel
api.videosApi.getVideoStreamUrl( api.videosApi.getVideoStreamUrl(
itemId = itemId, itemId = itemId,
mediaSourceId = source.id, mediaSourceId = source.id,
liveStreamId = source.liveStreamId,
static = true, static = true,
tag = source.eTag, tag = source.eTag,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
@ -955,7 +977,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex( streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo, source = currentPlayback.mediaSourceInfo,
audioStreamIndex = index, audioStreamIndex = index,
seriesId = currentItem.item.data.seriesId, item = currentItem.item.data,
subtitleIndex = currentPlayback.subtitleIndex, subtitleIndex = currentPlayback.subtitleIndex,
prefs = preferences, prefs = preferences,
) )
@ -991,7 +1013,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex( streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo, source = currentPlayback.mediaSourceInfo,
audioStreamIndex = currentPlayback.audioIndex, audioStreamIndex = currentPlayback.audioIndex,
seriesId = currentItem.item.data.seriesId, item = currentItem.item.data,
subtitleIndex = index, subtitleIndex = index,
prefs = preferences, prefs = preferences,
) )
@ -1009,6 +1031,26 @@ class PlaybackViewModel
} }
} }
fun changeMaxBitrate(newMaxBitrate: Long) {
viewModelScope.launchIO {
val currentPlayback = state.value.currentPlayback
if (currentPlayback != null) {
Timber.d("Changing bitrate to %s", newMaxBitrate)
changeStreams(
item = currentItem.item,
sourceId = currentPlayback.mediaSourceInfo.id,
audioIndex = currentPlayback.audioIndex,
subtitleIndex = currentPlayback.subtitleIndex,
positionMs = onMain { player.currentPosition },
enableDirectPlay = true,
maxBitrate = newMaxBitrate,
)
} else {
Timber.w("Trying to change bitrate, but currentPlayback is null")
}
}
}
internal suspend fun saveTrackSelection( internal suspend fun saveTrackSelection(
trackIndex: Int, trackIndex: Int,
type: MediaStreamType, type: MediaStreamType,
@ -1110,7 +1152,7 @@ class PlaybackViewModel
} }
// Variables for tracking segment state // Variables for tracking segment state
// Exposed for testing @VisibleForTesting
internal var segmentJob: Job? = null internal var segmentJob: Job? = null
private val autoSkippedSegments = mutableSetOf<UUID>() private val autoSkippedSegments = mutableSetOf<UUID>()
private val outroShownSegments = mutableSetOf<UUID>() private val outroShownSegments = mutableSetOf<UUID>()

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -122,6 +123,10 @@ sealed interface PlaybackAction {
data object Previous : PlaybackAction data object Previous : PlaybackAction
data object Next : PlaybackAction data object Next : PlaybackAction
data class ChangeBitrate(
val maxBitrate: Long,
) : PlaybackAction
} }
/** /**
@ -561,12 +566,19 @@ fun <T> BottomDialog(
gravity: Int, gravity: Int,
currentChoice: BottomDialogItem<T>? = null, currentChoice: BottomDialogItem<T>? = null,
) { ) {
val initialIndex =
remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0
val listState =
rememberLazyListState(
initialFirstVisibleItemIndex = initialIndex,
)
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
val bringIntoViewRequesters =
remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
if (currentChoice != null) { if (currentChoice != null) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
choices.indexOfFirstOrNull { it == currentChoice }?.let { bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
focusRequesters.getOrNull(it)?.tryRequestFocus() focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
}
} }
} }
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
@ -591,6 +603,7 @@ fun <T> BottomDialog(
), ),
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -624,7 +637,10 @@ fun <T> BottomDialog(
} }
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]), modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequesters[index])
.focusRequester(focusRequesters[index]),
) )
} }
} }

View file

@ -1,6 +1,13 @@
package com.github.damontecres.wholphin.ui.preferences package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -38,9 +45,53 @@ enum class PreferenceScreenOption {
SCREENSAVER, SCREENSAVER,
SKIP_SEGMENTS, SKIP_SEGMENTS,
EXPERIMENTAL, EXPERIMENTAL,
HDR_FORMATS,
; ;
companion object { companion object {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
} }
} }
@Composable
fun rememberSeerrDialogBody(
user: SeerrUser,
server: SeerrServer,
pluginProvided: Boolean,
ex: Exception? = null,
): String {
val username =
when (user.authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> user.username ?: stringResource(R.string.unknown)
SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key)
}
val method =
when (user.authMethod) {
SeerrAuthMethod.LOCAL -> R.string.seerr_local_user
SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user
SeerrAuthMethod.API_KEY -> 0
}.let {
if (it != 0) " (${stringResource(it)})" else ""
}
val body =
"""
${stringResource(R.string.url)}: ${server.url}
${stringResource(R.string.username)}: $username$method
""".trimIndent() +
if (pluginProvided) {
"\n\n${stringResource(R.string.managed_by_server)}"
} else {
""
} +
if (ex != null) {
"\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}"
} else {
""
}
return remember { body }
}

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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference import com.github.damontecres.wholphin.preferences.ExperimentalPreference
import com.github.damontecres.wholphin.preferences.HdrFormatPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.ScreensaverPreference
@ -74,9 +77,11 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -98,6 +103,7 @@ fun PreferencesContent(
onFocus: (Int, Int) -> Unit = { _, _ -> }, onFocus: (Int, Int) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() } val firstFocusRequester = remember { FocusRequester() }
@ -110,6 +116,11 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) }
val players by viewModel.externalPlayers.collectAsState() val players by viewModel.externalPlayers.collectAsState()
val serverSupported by viewModel.serverSupported.collectAsState()
val serverVersionMessage =
remember(resources, serverSupported) {
resources.serverSupportedMessage(serverSupported.second, serverSupported.first)
}
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState() val seerrConnection by viewModel.seerrConnection.collectAsState()
@ -155,6 +166,7 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -165,6 +177,7 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats
} }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@ -224,6 +237,19 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
serverVersionMessage?.let { message ->
item {
ListItem(
selected = false,
enabled = true,
onClick = {},
headlineContent = {
PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
},
modifier = Modifier,
)
}
}
if (showUpdate) { if (showUpdate) {
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -259,6 +285,7 @@ fun PreferencesContent(
) )
} }
} }
prefList.forEachIndexed { groupIndex, group -> prefList.forEachIndexed { groupIndex, group ->
item { item {
Text( Text(
@ -475,10 +502,11 @@ fun PreferencesContent(
title = stringResource(pref.title), title = stringResource(pref.title),
onClick = { onClick = {
seerrDialogMode = seerrDialogMode =
when (val conn = seerrConnection) { when (val conn = seerrConnection.connection) {
is SeerrConnectionStatus.Error -> { is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error( SeerrDialogMode.Error(
conn.server.url, conn.server,
conn.user,
conn.ex, conn.ex,
) )
} }
@ -489,22 +517,20 @@ fun PreferencesContent(
is SeerrConnectionStatus.Success -> { is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove( SeerrDialogMode.Remove(
conn.current.server.url, conn.current,
) )
} }
} }
}, },
modifier = focusModifier, modifier = focusModifier,
summary = summary =
when (seerrConnection) { when (seerrConnection.connection) {
is SeerrConnectionStatus.Error -> { is SeerrConnectionStatus.Error -> {
stringResource(R.string.voice_error_server) stringResource(R.string.voice_error_server)
} }
SeerrConnectionStatus.NotConfigured -> { SeerrConnectionStatus.NotConfigured -> {
stringResource( stringResource(R.string.add_server)
R.string.add_server,
)
} }
is SeerrConnectionStatus.Success -> { is SeerrConnectionStatus.Success -> {
@ -699,64 +725,15 @@ fun PreferencesContent(
) )
} }
} }
when (val mode = seerrDialogMode) {
is SeerrDialogMode.Remove -> {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = mode.serverUrl,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
seerrDialogMode = SeerrDialogMode.None
},
)
}
SeerrDialogMode.Add -> {
val currentUser by seerrVm.currentUser.collectAsState(null)
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
val serverAddedMessage = stringResource(R.string.seerr_server_added)
LaunchedEffect(status) {
if (status == LoadingState.Success) {
Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
seerrDialogMode = SeerrDialogMode.None
}
}
AddSeerServerDialog(
currentUsername = currentUser?.name,
status = status,
onSubmit = seerrVm::submitServer,
onResetStatus = seerrVm::resetStatus,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
)
}
is SeerrDialogMode.Error -> {
val errorStr = stringResource(R.string.voice_error_server)
val body =
remember(mode) {
"""
${mode.serverUrl}
$errorStr: ${mode.ex.localizedMessage}
""".trimIndent()
}
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = body,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
seerrDialogMode = SeerrDialogMode.None
},
bodyColor = MaterialTheme.colorScheme.error,
)
}
SeerrDialogMode.None -> {}
}
} }
SeerrDialog(
seerrDialogMode = seerrDialogMode,
seerrConnection = seerrConnection,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
seerrVm = seerrVm,
)
if (showQuickConnectDialog) { if (showQuickConnectDialog) {
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
val successMessage = stringResource(R.string.quick_connect_success) val successMessage = stringResource(R.string.quick_connect_success)
@ -838,6 +815,7 @@ fun PreferencesPage(
PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.SKIP_SEGMENTS,
PreferenceScreenOption.EXPERIMENTAL, PreferenceScreenOption.EXPERIMENTAL,
PreferenceScreenOption.HDR_FORMATS,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, initialPreferences,
@ -857,18 +835,3 @@ data class CacheUsage(
val imageMemoryMax: Long, val imageMemoryMax: Long,
val imageDiskUsed: Long, val imageDiskUsed: Long,
) )
private sealed interface SeerrDialogMode {
data object None : SeerrDialogMode
data object Add : SeerrDialogMode
data class Remove(
val serverUrl: String,
) : SeerrDialogMode
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrDialogMode
}

View file

@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -76,6 +79,23 @@ class PreferencesViewModel
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList()) val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList())
val serverSupported =
serverRepository.currentServerFlow
.map {
val serverVersion = it?.serverVersion
serverVersion to
when {
serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED
serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED
serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
else -> ServerVersionSupported.SUPPORTED
}
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null to ServerVersionSupported.SUPPORTED,
)
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
val fakeIntent = val fakeIntent =

View file

@ -32,6 +32,7 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
@ -177,7 +178,12 @@ fun ServerIconCard(
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
tint = MaterialTheme.colorScheme.errorContainer, tint =
if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
AppColors.GoldenYellow
} else {
MaterialTheme.colorScheme.errorContainer
},
modifier = Modifier.size(cardSize * 0.3f), modifier = Modifier.size(cardSize * 0.3f),
) )
Text( Text(

View file

@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -66,11 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber import timber.log.Timber
@Composable @Composable
@ -101,6 +103,8 @@ private fun SwitchServerContentInternal(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val resources = LocalResources.current val resources = LocalResources.current
val focusRequesters =
remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } }
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
var focusedIndex by rememberInt(0) var focusedIndex by rememberInt(0)
@ -144,10 +148,13 @@ private fun SwitchServerContentInternal(
val firstServerFocus = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() }
LaunchedEffect(state.loading) { LaunchedEffect(state.loading) {
val state = state val state = state
if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { when (state.loading) {
firstServerFocus.tryRequestFocus() is LoadingState.Error,
} else if (state.loading == LoadingState.Pending) { LoadingState.Loading,
firstServerFocus.tryRequestFocus() LoadingState.Pending,
-> Unit
LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus()
} }
} }
LazyRow( LazyRow(
@ -188,10 +195,7 @@ private fun SwitchServerContentInternal(
Modifier Modifier
.onFocusChanged { .onFocusChanged {
if (it.isFocused) focusedIndex = index if (it.isFocused) focusedIndex = index
}.ifElse( }.focusRequester(focusRequesters[index]),
index == 0,
Modifier.focusRequester(firstServerFocus),
),
) )
} }
// Add Server card - always rightmost // Add Server card - always rightmost
@ -202,10 +206,7 @@ private fun SwitchServerContentInternal(
Modifier Modifier
.onFocusChanged { .onFocusChanged {
if (it.isFocused) focusedIndex = -1 if (it.isFocused) focusedIndex = -1
}.ifElse( }.focusRequester(focusRequesters.last()),
state.servers.isEmpty(),
Modifier.focusRequester(firstServerFocus),
),
) )
} }
} }
@ -235,20 +236,7 @@ private fun SwitchServerContentInternal(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) { message -> ) { message ->
SwitchErrorMessage(message ?: "")
Text(
text = message ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
.align(Alignment.BottomCenter),
)
} }
// Delete server dialog // Delete server dialog
@ -327,12 +315,12 @@ private fun AddServerDialog(
} }
} }
val firstDiscoveredServerFocusRequester = remember { FocusRequester() } val buttonFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available // Default focus to first discovered server if available
LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { LaunchedEffect(showEnterAddress) {
if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { if (!showEnterAddress) {
firstDiscoveredServerFocusRequester.tryRequestFocus() buttonFocusRequester.tryRequestFocus()
} }
} }
@ -379,12 +367,8 @@ private fun AddServerDialog(
key = { state.discoveredServers[it].url }, key = { state.discoveredServers[it].url },
) { index -> ) { index ->
val server = state.discoveredServers[index] val server = state.discoveredServers[index]
val focusRequester = val focusRequester = remember { FocusRequester() }
if (index == 0) { LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() }
firstDiscoveredServerFocusRequester
} else {
remember { FocusRequester() }
}
ListItem( ListItem(
enabled = true, enabled = true,
@ -491,21 +475,38 @@ fun serverErrorMessage(
server: ServerState?, server: ServerState?,
resources: Resources, resources: Resources,
): String? = ): String? =
when { if (server == null || server.status == ServerConnectionStatus.Pending) {
server?.status is ServerConnectionStatus.Error -> { null
server.status.message } else if (server.status is ServerConnectionStatus.Success) {
} resources.serverSupportedMessage(
server.versionSupported,
server?.status is ServerConnectionStatus.Success && server.status.systemInfo.version
server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { ?.let { ServerVersion.fromString(it) },
resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" )
} } else if (server.status is ServerConnectionStatus.Error) {
server.status.message
server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { } else {
resources.getString(R.string.server_version_not_supported) resources.serverSupportedMessage(
} server.versionSupported,
null,
else -> { )
null
}
} }
@Composable
fun BoxScope.SwitchErrorMessage(message: String?) {
message?.let {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
.align(Alignment.BottomCenter),
)
}
}

View file

@ -97,6 +97,8 @@ class SwitchServerViewModel
result.systemInfo.version?.let { ServerVersion.fromString(it) } result.systemInfo.version?.let { ServerVersion.fromString(it) }
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED ServerVersionSupported.NOT_SUPPORTED
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else { } else {
ServerVersionSupported.SUPPORTED ServerVersionSupported.SUPPORTED
} }
@ -307,4 +309,5 @@ enum class ServerVersionSupported {
SUPPORTED, SUPPORTED,
NOT_SUPPORTED, NOT_SUPPORTED,
UNKNOWN, UNKNOWN,
NOT_SUPPORTED_IN_FUTURE,
} }

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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -183,37 +183,15 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
when (state.serverVersionSupported) { val resources = LocalResources.current
ServerVersionSupported.SUPPORTED -> {} val message =
remember(resources, state.serverVersionSupported) {
ServerVersionSupported.NOT_SUPPORTED, resources.serverSupportedMessage(
ServerVersionSupported.UNKNOWN, state.serverVersionSupported,
-> { state.serverVersion,
val resources = LocalResources.current
val message =
remember(resources) {
if (state.serverVersion.isNotNullOrBlank()) {
resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}"
} else {
resources.getString(R.string.server_version_not_supported) + ": " +
resources.getString(R.string.unknown)
}
}
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
.align(Alignment.BottomCenter),
) )
} }
} SwitchErrorMessage(message)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show more