mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Add integration with Jellyseerr/Seerr (#558)
## Description This PR add the ability to integrate with a [Jellyseerr/Seerr server](https://github.com/seerr-team/seerr). ### Features - Login to the Seerr server using API Key, Jellyfin credentials, or local Seerr credentials - Separate Seerr logins per user profile - Search Seerr from the search page - Discover media from Seerr on the nav drawer `Discover` page - Also from movie, series, or person pages - Seamless integration with existing media, i.e. selecting media found from Seerr that already exists on the Jellyfin server, will go directly to the regular media page - Mostly consistent UI between Jellyfin media & Seerr media - Submit requests to Seerr - Cancel submitted requests ### Screenshots  ### Related issues Closes #54 ## Acknowledgements The `app/src/main/seerr/seerr-api.yml` file is copied & modified from https://github.com/seerr-team/seerr/blob/develop/seerr-api.yml. I hope to try to upstream some of the changes in the future.
This commit is contained in:
parent
f4e1b0e171
commit
4c7c465c67
68 changed files with 13369 additions and 137 deletions
|
|
@ -15,6 +15,7 @@ plugins {
|
||||||
alias(libs.plugins.protobuf)
|
alias(libs.plugins.protobuf)
|
||||||
alias(libs.plugins.kotlin.plugin.serialization)
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
|
alias(libs.plugins.openapi.generator)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
||||||
|
|
@ -145,6 +146,12 @@ android {
|
||||||
isUniversalApk = true
|
isUniversalApk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
|
|
@ -176,6 +183,33 @@ aboutLibraries {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openApiGenerate {
|
||||||
|
generatorName.set("kotlin")
|
||||||
|
inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml")
|
||||||
|
templateDir.set("$projectDir/src/main/seerr/templates")
|
||||||
|
outputDir.set("$buildDir/generated/seerr_api")
|
||||||
|
apiPackage.set("com.github.damontecres.wholphin.api.seerr")
|
||||||
|
modelPackage.set("com.github.damontecres.wholphin.api.seerr.model")
|
||||||
|
groupId.set("com.github.damontecres.wholphin.api.seerr")
|
||||||
|
id.set("seerr-api")
|
||||||
|
packageName.set("com.github.damontecres.wholphin.api.seerr")
|
||||||
|
additionalProperties.apply {
|
||||||
|
put("serializationLibrary", "kotlinx_serialization")
|
||||||
|
put("sortModelPropertiesByRequiredFlag", true)
|
||||||
|
put("sortParamsByRequiredFlag", true)
|
||||||
|
put("useCoroutines", true)
|
||||||
|
put("enumPropertyNaming", "UPPERCASE")
|
||||||
|
put("modelMutable", false)
|
||||||
|
|
||||||
|
// Note: this is only for downloading files, so it's not necessary to enable
|
||||||
|
put("supportAndroidApiLevel25AndBelow", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("preBuild") {
|
||||||
|
dependsOn.add(tasks.named("openApiGenerate"))
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
@ -246,6 +280,8 @@ dependencies {
|
||||||
implementation(libs.acra.limiter)
|
implementation(libs.acra.limiter)
|
||||||
compileOnly(libs.auto.service.annotations)
|
compileOnly(libs.auto.service.annotations)
|
||||||
ksp(libs.auto.service.ksp)
|
ksp(libs.auto.service.ksp)
|
||||||
|
implementation(platform(libs.okhttp.bom))
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,549 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 20,
|
||||||
|
"identityHash": "dea51adcc724179afa0174d775f97480",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` 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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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": "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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 'dea51adcc724179afa0174d775f97480')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.services.ServerEventListener
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
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.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
|
|
@ -105,6 +106,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var refreshRateService: RefreshRateService
|
lateinit var refreshRateService: RefreshRateService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userSwitchListener: UserSwitchListener
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package com.github.damontecres.wholphin.api.seerr
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class SeerrApiClient(
|
||||||
|
val baseUrl: String,
|
||||||
|
private val apiKey: String?,
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val cookieJar = SeerrCookieJar()
|
||||||
|
|
||||||
|
private val client =
|
||||||
|
okHttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.cookieJar(cookieJar)
|
||||||
|
.addInterceptor {
|
||||||
|
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
|
||||||
|
it.proceed(
|
||||||
|
it
|
||||||
|
.request()
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey)
|
||||||
|
}.build(),
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val hasValidCredentials: Boolean
|
||||||
|
get() =
|
||||||
|
apiKey.isNotNullOrBlank() ||
|
||||||
|
cookieJar.hasValidCredentials(baseUrl)
|
||||||
|
|
||||||
|
private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> =
|
||||||
|
lazy {
|
||||||
|
initializer.invoke(baseUrl, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
val authApi by create(::AuthApi)
|
||||||
|
val blacklistApi by create(::BlacklistApi)
|
||||||
|
val collectionApi by create(::CollectionApi)
|
||||||
|
val issueApi by create(::IssueApi)
|
||||||
|
val mediaApi by create(::MediaApi)
|
||||||
|
val moviesApi by create(::MoviesApi)
|
||||||
|
val otherApi by create(::OtherApi)
|
||||||
|
val overrideruleApi by create(::OverrideruleApi)
|
||||||
|
val personApi by create(::PersonApi)
|
||||||
|
val publicApi by create(::PublicApi)
|
||||||
|
val requestApi by create(::RequestApi)
|
||||||
|
val searchApi by create(::SearchApi)
|
||||||
|
val serviceApi by create(::ServiceApi)
|
||||||
|
val settingsApi by create(::SettingsApi)
|
||||||
|
val tmdbApi by create(::TmdbApi)
|
||||||
|
val tvApi by create(::TvApi)
|
||||||
|
val usersApi by create(::UsersApi)
|
||||||
|
val watchlistApi by create(::WatchlistApi)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SeerrCookieJar : CookieJar {
|
||||||
|
private val cookies = mutableMapOf<String, List<Cookie>>()
|
||||||
|
|
||||||
|
override fun saveFromResponse(
|
||||||
|
url: HttpUrl,
|
||||||
|
cookies: List<Cookie>,
|
||||||
|
) {
|
||||||
|
cookies
|
||||||
|
.filter { it.name == "connect.sid" }
|
||||||
|
.groupBy { it.domain }
|
||||||
|
.forEach { (domain, cookies) ->
|
||||||
|
this.cookies[domain] = cookies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> = this.cookies[url.host].orEmpty()
|
||||||
|
|
||||||
|
fun hasValidCredentials(baseUrl: String): Boolean =
|
||||||
|
baseUrl.toHttpUrlOrNull()?.host?.let { domain ->
|
||||||
|
cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() }
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
@ -32,8 +34,10 @@ import java.util.UUID
|
||||||
LibraryDisplayInfo::class,
|
LibraryDisplayInfo::class,
|
||||||
PlaybackLanguageChoice::class,
|
PlaybackLanguageChoice::class,
|
||||||
ItemTrackModification::class,
|
ItemTrackModification::class,
|
||||||
|
SeerrServer::class,
|
||||||
|
SeerrUser::class,
|
||||||
],
|
],
|
||||||
version = 12,
|
version = 20,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -45,6 +49,7 @@ import java.util.UUID
|
||||||
AutoMigration(9, 10),
|
AutoMigration(9, 10),
|
||||||
AutoMigration(10, 11),
|
AutoMigration(10, 11),
|
||||||
AutoMigration(11, 12),
|
AutoMigration(11, 12),
|
||||||
|
AutoMigration(12, 20),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||||
|
|
||||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||||
|
|
||||||
|
abstract fun seerrServerDao(): SeerrServerDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
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.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
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.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
|
@ -24,6 +26,7 @@ class NavDrawerItemRepository
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
private val serverPreferencesDao: ServerPreferencesDao,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
||||||
val user = serverRepository.currentUser.value
|
val user = serverRepository.currentUser.value
|
||||||
|
|
@ -46,7 +49,13 @@ class NavDrawerItemRepository
|
||||||
setOf()
|
setOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
val builtins = listOf(NavDrawerItem.Favorites)
|
val builtins =
|
||||||
|
if (seerrServerRepository.active.first()) {
|
||||||
|
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||||
|
} else {
|
||||||
|
listOf(NavDrawerItem.Favorites)
|
||||||
|
}
|
||||||
|
|
||||||
val libraries =
|
val libraries =
|
||||||
userViews
|
userViews
|
||||||
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Update
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrServerUsers
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SeerrServerDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun addServer(server: SeerrServer): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateServer(server: SeerrServer): Int
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun addOrUpdateServer(server: SeerrServer) {
|
||||||
|
val result = addServer(server)
|
||||||
|
if (result == -1L) {
|
||||||
|
updateServer(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun addUser(user: SeerrUser): Long
|
||||||
|
|
||||||
|
suspend fun updateUser(user: SeerrUser) = addUser(user)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||||
|
suspend fun getUser(
|
||||||
|
serverId: Int,
|
||||||
|
jellyfinUserRowId: Int,
|
||||||
|
): SeerrUser?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId")
|
||||||
|
suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser>
|
||||||
|
|
||||||
|
@Query("DELETE FROM seerr_servers WHERE id = :serverId")
|
||||||
|
suspend fun deleteServer(serverId: Int)
|
||||||
|
|
||||||
|
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||||
|
suspend fun deleteUser(
|
||||||
|
serverId: Int,
|
||||||
|
jellyfinUserRowId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM seerr_servers")
|
||||||
|
suspend fun getServers(): List<SeerrServerUsers>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM seerr_servers WHERE id = :serverId")
|
||||||
|
suspend fun getServer(serverId: Int): SeerrServerUsers?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM seerr_servers WHERE url = :url")
|
||||||
|
suspend fun getServer(url: String): SeerrServerUsers?
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,9 @@ data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
override val id get() = data.id
|
val id get() = data.id
|
||||||
|
|
||||||
|
override val gridId get() = id.toString()
|
||||||
|
|
||||||
override val playable: Boolean
|
override val playable: Boolean
|
||||||
get() = type.playable
|
get() = type.playable
|
||||||
|
|
@ -88,23 +90,21 @@ data class BaseItem(
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
this,
|
|
||||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||||
)
|
)
|
||||||
} ?: Destination.MediaItem(id, type, this)
|
} ?: Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.SEASON -> {
|
BaseItemKind.SEASON -> {
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
this,
|
|
||||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Destination.MediaItem(id, type, this)
|
Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
@file:UseSerializers(UUIDSerializer::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrSearchResult
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.toLocalDate
|
||||||
|
import com.github.damontecres.wholphin.util.LocalDateSerializer
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SeerrItemType(
|
||||||
|
val baseItemKind: BaseItemKind?,
|
||||||
|
) {
|
||||||
|
@SerialName("movie")
|
||||||
|
MOVIE(BaseItemKind.MOVIE),
|
||||||
|
|
||||||
|
@SerialName("tv")
|
||||||
|
TV(BaseItemKind.SERIES),
|
||||||
|
|
||||||
|
@SerialName("person")
|
||||||
|
PERSON(BaseItemKind.PERSON),
|
||||||
|
|
||||||
|
@SerialName("unknown")
|
||||||
|
UNKNOWN(null),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(
|
||||||
|
str: String?,
|
||||||
|
fallback: SeerrItemType = UNKNOWN,
|
||||||
|
) = when (str) {
|
||||||
|
"movie" -> MOVIE
|
||||||
|
"tv" -> TV
|
||||||
|
"person" -> PERSON
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SeerrAvailability(
|
||||||
|
val status: Int,
|
||||||
|
) {
|
||||||
|
UNKNOWN(1),
|
||||||
|
PENDING(2),
|
||||||
|
PROCESSING(3),
|
||||||
|
PARTIALLY_AVAILABLE(4),
|
||||||
|
AVAILABLE(5),
|
||||||
|
DELETED(6),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(status: Int?) = entries.firstOrNull { it.status == status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DiscoverItem(
|
||||||
|
val id: Int,
|
||||||
|
val type: SeerrItemType,
|
||||||
|
val title: String?,
|
||||||
|
val subtitle: String?,
|
||||||
|
val overview: String?,
|
||||||
|
val availability: SeerrAvailability,
|
||||||
|
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
||||||
|
val posterPath: String?,
|
||||||
|
val backdropPath: String?,
|
||||||
|
val jellyfinItemId: UUID?,
|
||||||
|
) : CardGridItem {
|
||||||
|
override val gridId: String get() = id.toString()
|
||||||
|
override val playable: Boolean = false
|
||||||
|
override val sortName: String get() = title ?: ""
|
||||||
|
|
||||||
|
val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" }
|
||||||
|
val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
|
||||||
|
|
||||||
|
val destination: Destination
|
||||||
|
get() {
|
||||||
|
val jfType =
|
||||||
|
when (type) {
|
||||||
|
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
|
||||||
|
SeerrItemType.TV -> BaseItemKind.SERIES
|
||||||
|
SeerrItemType.PERSON -> BaseItemKind.PERSON
|
||||||
|
SeerrItemType.UNKNOWN -> null
|
||||||
|
}
|
||||||
|
return if (jellyfinItemId != null && jfType != null) {
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = jellyfinItemId,
|
||||||
|
type = jfType,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.DiscoveredItem(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(movie: MovieResult) : this(
|
||||||
|
id = movie.id,
|
||||||
|
type = SeerrItemType.MOVIE,
|
||||||
|
title = movie.title,
|
||||||
|
subtitle = null,
|
||||||
|
overview = movie.overview,
|
||||||
|
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(movie.releaseDate),
|
||||||
|
posterPath = movie.posterPath,
|
||||||
|
backdropPath = movie.backdropPath,
|
||||||
|
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(movie: MovieDetails) : this(
|
||||||
|
id = movie.id ?: -1,
|
||||||
|
type = SeerrItemType.MOVIE,
|
||||||
|
title = movie.title,
|
||||||
|
subtitle = null,
|
||||||
|
overview = movie.overview,
|
||||||
|
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(movie.releaseDate),
|
||||||
|
posterPath = movie.posterPath,
|
||||||
|
backdropPath = movie.backdropPath,
|
||||||
|
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(tv: TvResult) : this(
|
||||||
|
id = tv.id!!,
|
||||||
|
type = SeerrItemType.TV,
|
||||||
|
title = tv.name,
|
||||||
|
subtitle = null,
|
||||||
|
overview = tv.overview,
|
||||||
|
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(tv.firstAirDate),
|
||||||
|
posterPath = tv.posterPath,
|
||||||
|
backdropPath = tv.backdropPath,
|
||||||
|
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(tv: TvDetails) : this(
|
||||||
|
id = tv.id!!,
|
||||||
|
type = SeerrItemType.TV,
|
||||||
|
title = tv.name,
|
||||||
|
subtitle = null,
|
||||||
|
overview = tv.overview,
|
||||||
|
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(tv.firstAirDate),
|
||||||
|
posterPath = tv.posterPath,
|
||||||
|
backdropPath = tv.backdropPath,
|
||||||
|
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(search: SeerrSearchResult) : this(
|
||||||
|
id = search.id,
|
||||||
|
type = SeerrItemType.fromString(search.mediaType),
|
||||||
|
title = search.title ?: search.name,
|
||||||
|
subtitle = null,
|
||||||
|
overview = search.overview,
|
||||||
|
availability =
|
||||||
|
SeerrAvailability.from(search.mediaInfo?.status)
|
||||||
|
?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||||
|
posterPath = search.posterPath,
|
||||||
|
backdropPath = search.backdropPath,
|
||||||
|
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(credit: CreditCast) : this(
|
||||||
|
id = credit.id!!,
|
||||||
|
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||||
|
title = credit.name ?: credit.title,
|
||||||
|
subtitle = null,
|
||||||
|
overview = credit.overview,
|
||||||
|
availability =
|
||||||
|
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||||
|
?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(credit.firstAirDate),
|
||||||
|
posterPath = credit.posterPath ?: credit.profilePath,
|
||||||
|
backdropPath = credit.backdropPath,
|
||||||
|
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(credit: CreditCrew) : this(
|
||||||
|
id = credit.id!!,
|
||||||
|
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||||
|
title = credit.name ?: credit.title,
|
||||||
|
subtitle = null,
|
||||||
|
overview = credit.overview,
|
||||||
|
availability =
|
||||||
|
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||||
|
?: SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = toLocalDate(credit.firstAirDate),
|
||||||
|
posterPath = credit.posterPath ?: credit.profilePath,
|
||||||
|
backdropPath = credit.backdropPath,
|
||||||
|
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DiscoverRating(
|
||||||
|
val criticRating: Int?,
|
||||||
|
val audienceRating: Float?,
|
||||||
|
) {
|
||||||
|
constructor(rating: MovieMovieIdRatingsGet200Response) : this(
|
||||||
|
criticRating = rating.criticsScore,
|
||||||
|
audienceRating = rating.audienceScore?.div(10f),
|
||||||
|
)
|
||||||
|
constructor(rating: TvTvIdRatingsGet200Response) : this(
|
||||||
|
criticRating = rating.criticsScore,
|
||||||
|
audienceRating = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
|
|
||||||
|
enum class SeerrPermission(
|
||||||
|
private val flag: Int,
|
||||||
|
) {
|
||||||
|
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
|
||||||
|
NONE(0),
|
||||||
|
ADMIN(2),
|
||||||
|
MANAGE_SETTINGS(4),
|
||||||
|
MANAGE_USERS(8),
|
||||||
|
MANAGE_REQUESTS(16),
|
||||||
|
REQUEST(32),
|
||||||
|
VOTE(64),
|
||||||
|
AUTO_APPROVE(128),
|
||||||
|
AUTO_APPROVE_MOVIE(256),
|
||||||
|
AUTO_APPROVE_TV(512),
|
||||||
|
REQUEST_4K(1024),
|
||||||
|
REQUEST_4K_MOVIE(2048),
|
||||||
|
REQUEST_4K_TV(4096),
|
||||||
|
REQUEST_ADVANCED(8192),
|
||||||
|
REQUEST_VIEW(16384),
|
||||||
|
AUTO_APPROVE_4K(32768),
|
||||||
|
AUTO_APPROVE_4K_MOVIE(65536),
|
||||||
|
AUTO_APPROVE_4K_TV(131072),
|
||||||
|
REQUEST_MOVIE(262144),
|
||||||
|
REQUEST_TV(524288),
|
||||||
|
MANAGE_ISSUES(1048576),
|
||||||
|
VIEW_ISSUES(2097152),
|
||||||
|
CREATE_ISSUES(4194304),
|
||||||
|
AUTO_REQUEST(8388608),
|
||||||
|
AUTO_REQUEST_MOVIE(16777216),
|
||||||
|
AUTO_REQUEST_TV(33554432),
|
||||||
|
RECENT_VIEW(67108864),
|
||||||
|
WATCHLIST_VIEW(134217728),
|
||||||
|
MANAGE_BLACKLIST(268435456),
|
||||||
|
VIEW_BLACKLIST(1073741824),
|
||||||
|
;
|
||||||
|
|
||||||
|
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the user has the given permissions (or is an admin)
|
||||||
|
*/
|
||||||
|
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
|
||||||
|
return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
@file:UseSerializers(UUIDSerializer::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Relation
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "seerr_servers",
|
||||||
|
indices = [Index("url", unique = true)],
|
||||||
|
)
|
||||||
|
@Serializable
|
||||||
|
data class SeerrServer(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Int = 0,
|
||||||
|
val url: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val version: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "seerr_users",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = SeerrServer::class,
|
||||||
|
parentColumns = arrayOf("id"),
|
||||||
|
childColumns = arrayOf("serverId"),
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = JellyfinUser::class,
|
||||||
|
parentColumns = arrayOf("rowId"),
|
||||||
|
childColumns = arrayOf("jellyfinUserRowId"),
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
primaryKeys = ["jellyfinUserRowId", "serverId"],
|
||||||
|
)
|
||||||
|
data class SeerrUser(
|
||||||
|
val jellyfinUserRowId: Int,
|
||||||
|
val serverId: Int,
|
||||||
|
val authMethod: SeerrAuthMethod,
|
||||||
|
val username: String?,
|
||||||
|
val password: String?,
|
||||||
|
val credential: String?,
|
||||||
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SeerrAuthMethod {
|
||||||
|
LOCAL,
|
||||||
|
JELLYFIN,
|
||||||
|
API_KEY,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SeerrServerUsers(
|
||||||
|
@Embedded val server: SeerrServer,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "id",
|
||||||
|
entityColumn = "serverId",
|
||||||
|
)
|
||||||
|
val users: List<SeerrUser>,
|
||||||
|
)
|
||||||
|
|
@ -871,6 +871,13 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOn = R.string.enabled,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val SeerrIntegration =
|
||||||
|
AppClickablePreference<AppPreferences>(
|
||||||
|
title = R.string.seerr_integration,
|
||||||
|
getter = { },
|
||||||
|
setter = { prefs, _ -> prefs },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -932,6 +939,7 @@ val basicPreferences =
|
||||||
title = R.string.more,
|
title = R.string.more,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
|
AppPreference.SeerrIntegration,
|
||||||
AppPreference.AdvancedSettings,
|
AppPreference.AdvancedSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import coil3.request.SuccessResult
|
||||||
import coil3.request.allowHardware
|
import coil3.request.allowHardware
|
||||||
import coil3.request.bitmapConfig
|
import coil3.request.bitmapConfig
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -47,15 +47,24 @@ class BackdropService
|
||||||
|
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||||
|
submit(item.id.toString(), imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||||
|
|
||||||
|
suspend fun submit(
|
||||||
|
itemId: String,
|
||||||
|
imageUrl: String?,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
itemId = item.id,
|
itemId = itemId,
|
||||||
imageUrl = null,
|
imageUrl = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
extractColors(item)
|
extractColors(itemId, imageUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,9 +74,11 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractColors(item: BaseItem) {
|
private suspend fun extractColors(
|
||||||
|
itemId: String,
|
||||||
|
imageUrl: String?,
|
||||||
|
) {
|
||||||
delay(500)
|
delay(500)
|
||||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
|
||||||
val backdropStyle =
|
val backdropStyle =
|
||||||
preferences.data
|
preferences.data
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
|
|
@ -83,9 +94,9 @@ class BackdropService
|
||||||
ExtractedColors.DEFAULT
|
ExtractedColors.DEFAULT
|
||||||
}
|
}
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
if (it.itemId == item.id) {
|
if (it.itemId == itemId) {
|
||||||
BackdropResult(
|
BackdropResult(
|
||||||
itemId = item.id,
|
itemId = itemId,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
primaryColor = primaryColor,
|
primaryColor = primaryColor,
|
||||||
secondaryColor = secondaryColor,
|
secondaryColor = secondaryColor,
|
||||||
|
|
@ -208,7 +219,7 @@ class BackdropService
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BackdropResult(
|
data class BackdropResult(
|
||||||
val itemId: UUID?,
|
val itemId: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val primaryColor: Color = Color.Unspecified,
|
val primaryColor: Color = Color.Unspecified,
|
||||||
val secondaryColor: Color = Color.Unspecified,
|
val secondaryColor: Color = Color.Unspecified,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead.
|
||||||
|
*/
|
||||||
|
class SeerrApi(
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
var api: SeerrApiClient =
|
||||||
|
SeerrApiClient(
|
||||||
|
baseUrl = "",
|
||||||
|
apiKey = null,
|
||||||
|
okHttpClient = okHttpClient,
|
||||||
|
)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String?,
|
||||||
|
) {
|
||||||
|
api = SeerrApiClient(baseUrl, apiKey, okHttpClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.User
|
||||||
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SeerrServerRepository
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val seerrApi: SeerrApi,
|
||||||
|
private val seerrServerDao: SeerrServerDao,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val _current = MutableStateFlow<CurrentSeerr?>(null)
|
||||||
|
val current: StateFlow<CurrentSeerr?> = _current
|
||||||
|
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
|
||||||
|
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Seerr integration is currently active of not
|
||||||
|
*/
|
||||||
|
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_current.update { null }
|
||||||
|
seerrApi.update("", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun set(
|
||||||
|
server: SeerrServer,
|
||||||
|
user: SeerrUser,
|
||||||
|
userConfig: SeerrUserConfig,
|
||||||
|
) {
|
||||||
|
_current.update {
|
||||||
|
CurrentSeerr(server, user, userConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addAndChangeServer(
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
) {
|
||||||
|
var server = seerrServerDao.getServer(url)
|
||||||
|
if (server == null) {
|
||||||
|
seerrServerDao.addServer(SeerrServer(url = url))
|
||||||
|
server = seerrServerDao.getServer(url)
|
||||||
|
}
|
||||||
|
server?.server?.let { server ->
|
||||||
|
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||||
|
// TODO test api key
|
||||||
|
val user =
|
||||||
|
SeerrUser(
|
||||||
|
jellyfinUserRowId = jellyfinUser.rowId,
|
||||||
|
serverId = server.id,
|
||||||
|
authMethod = SeerrAuthMethod.API_KEY,
|
||||||
|
username = null,
|
||||||
|
password = null,
|
||||||
|
credential = apiKey,
|
||||||
|
)
|
||||||
|
seerrServerDao.addUser(user)
|
||||||
|
|
||||||
|
seerrApi.update(server.url, apiKey)
|
||||||
|
val userConfig = seerrApi.api.usersApi.authMeGet()
|
||||||
|
set(server, user, userConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addAndChangeServer(
|
||||||
|
url: String,
|
||||||
|
authMethod: SeerrAuthMethod,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) {
|
||||||
|
var server = seerrServerDao.getServer(url)
|
||||||
|
if (server == null) {
|
||||||
|
seerrServerDao.addServer(SeerrServer(url = url))
|
||||||
|
server = seerrServerDao.getServer(url)
|
||||||
|
}
|
||||||
|
server?.server?.let { server ->
|
||||||
|
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||||
|
// TODO Need to update server early so that cookies are saved
|
||||||
|
seerrApi.update(server.url, null)
|
||||||
|
val userConfig = login(seerrApi.api, authMethod, username, password)
|
||||||
|
|
||||||
|
val user =
|
||||||
|
SeerrUser(
|
||||||
|
jellyfinUserRowId = jellyfinUser.rowId,
|
||||||
|
serverId = server.id,
|
||||||
|
authMethod = authMethod,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
credential = null,
|
||||||
|
)
|
||||||
|
seerrServerDao.addUser(user)
|
||||||
|
set(server, user, userConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun testConnection(
|
||||||
|
authMethod: SeerrAuthMethod,
|
||||||
|
url: String,
|
||||||
|
username: String?,
|
||||||
|
passwordOrApiKey: String,
|
||||||
|
): LoadingState {
|
||||||
|
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
|
||||||
|
try {
|
||||||
|
login(api, authMethod, username, passwordOrApiKey)
|
||||||
|
return LoadingState.Success
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error testing seerr connection")
|
||||||
|
return LoadingState.Error(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeServer() {
|
||||||
|
val current = _current.value ?: return
|
||||||
|
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [SeerrUser] config
|
||||||
|
*/
|
||||||
|
typealias SeerrUserConfig = User
|
||||||
|
|
||||||
|
data class CurrentSeerr(
|
||||||
|
val server: SeerrServer,
|
||||||
|
val user: SeerrUser,
|
||||||
|
val config: SeerrUserConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun login(
|
||||||
|
client: SeerrApiClient,
|
||||||
|
authMethod: SeerrAuthMethod,
|
||||||
|
username: String?,
|
||||||
|
password: String?,
|
||||||
|
): User =
|
||||||
|
when (authMethod) {
|
||||||
|
SeerrAuthMethod.LOCAL -> {
|
||||||
|
client.authApi.authLocalPost(
|
||||||
|
AuthLocalPostRequest(
|
||||||
|
email = username ?: "",
|
||||||
|
password = password ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAuthMethod.JELLYFIN -> {
|
||||||
|
client.authApi.authJellyfinPost(
|
||||||
|
AuthJellyfinPostRequest(
|
||||||
|
username = username ?: "",
|
||||||
|
password = password ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAuthMethod.API_KEY -> {
|
||||||
|
client.usersApi.authMeGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for JF user switching in the app to also switch the Seerr user/server
|
||||||
|
*/
|
||||||
|
@ActivityScoped
|
||||||
|
class UserSwitchListener
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ActivityContext private val context: Context,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val seerrServerDao: SeerrServerDao,
|
||||||
|
private val seerrApi: SeerrApi,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
context as AppCompatActivity
|
||||||
|
context.lifecycleScope.launchIO {
|
||||||
|
serverRepository.currentUser.asFlow().collect { user ->
|
||||||
|
Timber.d("New user")
|
||||||
|
seerrServerRepository.clear()
|
||||||
|
if (user != null) {
|
||||||
|
seerrServerDao
|
||||||
|
.getUsersByJellyfinUser(user.rowId)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { seerrUser ->
|
||||||
|
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||||
|
if (server != null) {
|
||||||
|
Timber.i("Found a seerr user & server")
|
||||||
|
seerrApi.update(server.url, seerrUser.credential)
|
||||||
|
val userConfig =
|
||||||
|
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||||
|
try {
|
||||||
|
login(
|
||||||
|
seerrApi.api,
|
||||||
|
seerrUser.authMethod,
|
||||||
|
seerrUser.username,
|
||||||
|
seerrUser.password,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error logging into %s", server.url)
|
||||||
|
seerrServerRepository.clear()
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
seerrApi.api.usersApi.authMeGet()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error logging into %s", server.url)
|
||||||
|
seerrServerRepository.clear()
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
typealias SeerrSearchResult = SearchGet200ResponseResultsInner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main access for the current Seerr server (if any)
|
||||||
|
*
|
||||||
|
* Exposes a [SeerrApiClient] for queries
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SeerrService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val seerApi: SeerrApi,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
) {
|
||||||
|
val api: SeerrApiClient get() = seerApi.api
|
||||||
|
|
||||||
|
val active get() = seerrServerRepository.active
|
||||||
|
|
||||||
|
suspend fun search(
|
||||||
|
query: String,
|
||||||
|
page: Int = 1,
|
||||||
|
): List<SeerrSearchResult> =
|
||||||
|
api.searchApi
|
||||||
|
.searchGet(query = query, page = page)
|
||||||
|
.results
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
suspend fun discoverTv(page: Int = 1): List<DiscoverItem> =
|
||||||
|
api.searchApi
|
||||||
|
.discoverTvGet(page = page)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
|
||||||
|
api.searchApi
|
||||||
|
.discoverMoviesGet(page = page)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
suspend fun trending(page: Int = 1): List<DiscoverItem> =
|
||||||
|
api.searchApi
|
||||||
|
.discoverTrendingGet(page = page)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
|
||||||
|
api.searchApi
|
||||||
|
.discoverMoviesUpcomingGet(page = page)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
|
||||||
|
api.searchApi
|
||||||
|
.discoverTvUpcomingGet(page = page)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get [DiscoverItem]s similar to the JF items such as movies, series, or people
|
||||||
|
*
|
||||||
|
* If Seerr integration is not active, this short circuits to return null
|
||||||
|
*
|
||||||
|
* @return the discovered items or null if no Seerr server configured
|
||||||
|
*/
|
||||||
|
suspend fun similar(item: BaseItem): List<DiscoverItem>? =
|
||||||
|
if (active.first()) {
|
||||||
|
item.data.providerIds
|
||||||
|
?.get("Tmdb")
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let {
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.MOVIE -> {
|
||||||
|
api.moviesApi
|
||||||
|
.movieMovieIdSimilarGet(movieId = it)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
|
||||||
|
api.tvApi
|
||||||
|
.tvTvIdSimilarGet(tvId = it)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.PERSON -> {
|
||||||
|
api.personApi
|
||||||
|
.personPersonIdCombinedCreditsGet(personId = it)
|
||||||
|
.let { credits ->
|
||||||
|
val cast =
|
||||||
|
credits.cast
|
||||||
|
?.take(25)
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
val crew =
|
||||||
|
credits.crew
|
||||||
|
?.take(25)
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
cast + crew
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.orEmpty()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,6 @@ class TrailerService
|
||||||
navigateTo.invoke(
|
navigateTo.invoke(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
itemId = trailer.baseItem.id,
|
itemId = trailer.baseItem.id,
|
||||||
item = trailer.baseItem,
|
|
||||||
positionMs = 0L,
|
positionMs = 0L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.headersContentLength
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -211,7 +210,7 @@ class UpdateChecker
|
||||||
if (it.isSuccessful && it.body != null) {
|
if (it.isSuccessful && it.body != null) {
|
||||||
Timber.v("Request successful for ${release.downloadUrl}")
|
Timber.v("Request successful for ${release.downloadUrl}")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
callback.contentLength(it.headersContentLength())
|
callback.contentLength(it.body.contentLength())
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val contentValues =
|
val contentValues =
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrApi
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -174,4 +175,10 @@ object AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@IoCoroutineScope
|
@IoCoroutineScope
|
||||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seerrApi(
|
||||||
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
|
) = SeerrApi(okHttpClient)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
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
|
||||||
|
|
@ -61,6 +62,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import androidx.annotation.StringRes
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -23,6 +25,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT
|
||||||
|
|
||||||
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
|
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
|
||||||
|
|
||||||
|
fun toLocalDate(date: String?): LocalDate? =
|
||||||
|
date?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
Timber.w("Could not parse date: %s", date)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the item has season & episode info, format as `S# E#`
|
* If the item has season & episode info, format as `S# E#`
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ val LocalImageUrlService =
|
||||||
/**
|
/**
|
||||||
* Colors not associated with the theme
|
* Colors not associated with the theme
|
||||||
*/
|
*/
|
||||||
sealed class AppColors private constructor() {
|
object AppColors {
|
||||||
companion object {
|
|
||||||
val TransparentBlack25 = Color(0x40000000)
|
val TransparentBlack25 = Color(0x40000000)
|
||||||
val TransparentBlack50 = Color(0x80000000)
|
val TransparentBlack50 = Color(0x80000000)
|
||||||
val TransparentBlack75 = Color(0xBF000000)
|
val TransparentBlack75 = Color(0xBF000000)
|
||||||
|
|
@ -33,6 +32,12 @@ sealed class AppColors private constructor() {
|
||||||
val DarkPurple = Color(0xFF261370)
|
val DarkPurple = Color(0xFF261370)
|
||||||
|
|
||||||
val GoldenYellow = Color(0xFFDAB440)
|
val GoldenYellow = Color(0xFFDAB440)
|
||||||
|
|
||||||
|
object Discover {
|
||||||
|
val Blue = Color(37, 99, 235)
|
||||||
|
val Purple = Color(147, 51, 234)
|
||||||
|
val Green = Color(74, 222, 128)
|
||||||
|
val Yellow = Color(234, 179, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Card
|
||||||
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
fun DiscoverItemCard(
|
||||||
|
item: DiscoverItem?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
showOverlay: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val hideOverlayDelay = 500L
|
||||||
|
if (focused) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(hideOverlayDelay)
|
||||||
|
if (focused) {
|
||||||
|
focusedAfterDelay = true
|
||||||
|
} else {
|
||||||
|
focusedAfterDelay = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusedAfterDelay = false
|
||||||
|
}
|
||||||
|
val width = Cards.height2x3 * AspectRatios.TALL
|
||||||
|
val height = Dp.Unspecified * (1f / AspectRatios.TALL)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
|
modifier = modifier.size(width, height),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(Dp.Unspecified, Cards.height2x3)
|
||||||
|
.aspectRatio(AspectRatios.TALL),
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors =
|
||||||
|
CardDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
ItemCardImage(
|
||||||
|
imageUrl = item?.posterUrl,
|
||||||
|
name = item?.title,
|
||||||
|
showOverlay = false,
|
||||||
|
favorite = false,
|
||||||
|
watched = false,
|
||||||
|
unwatchedCount = 0,
|
||||||
|
watchedPercent = null,
|
||||||
|
numberOfVersions = -1,
|
||||||
|
useFallbackText = false,
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
when (item?.availability) {
|
||||||
|
SeerrAvailability.PENDING,
|
||||||
|
SeerrAvailability.PROCESSING,
|
||||||
|
-> {
|
||||||
|
PendingIndicator(Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.PARTIALLY_AVAILABLE -> {
|
||||||
|
PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.AVAILABLE,
|
||||||
|
-> {
|
||||||
|
AvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
if (showOverlay) {
|
||||||
|
val color =
|
||||||
|
remember(item?.type) {
|
||||||
|
when (item?.type) {
|
||||||
|
SeerrItemType.MOVIE -> AppColors.Discover.Blue
|
||||||
|
SeerrItemType.TV -> AppColors.Discover.Purple
|
||||||
|
SeerrItemType.PERSON -> AppColors.Discover.Green
|
||||||
|
SeerrItemType.UNKNOWN -> Color.Black
|
||||||
|
null -> Color.Black
|
||||||
|
}.copy(alpha = .8f)
|
||||||
|
}
|
||||||
|
val text =
|
||||||
|
remember(item?.type) {
|
||||||
|
when (item?.type) {
|
||||||
|
SeerrItemType.MOVIE -> R.plurals.movies
|
||||||
|
SeerrItemType.TV -> R.plurals.tv_shows
|
||||||
|
SeerrItemType.PERSON -> R.plurals.people
|
||||||
|
SeerrItemType.UNKNOWN -> null
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text?.let {
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(it, 1),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(
|
||||||
|
color = color,
|
||||||
|
shape = CircleShape,
|
||||||
|
).padding(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = spaceBelow)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item?.title ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.enableMarquee(focusedAfterDelay),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item?.releaseDate?.year?.toString() ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.enableMarquee(focusedAfterDelay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PendingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.border(
|
||||||
|
width = .5.dp,
|
||||||
|
color = AppColors.Discover.Yellow,
|
||||||
|
shape = CircleShape,
|
||||||
|
).background(
|
||||||
|
color = Color.White.copy(alpha = .85f),
|
||||||
|
shape = CircleShape,
|
||||||
|
).size(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_bell),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = AppColors.Discover.Yellow,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AvailableIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.border(
|
||||||
|
width = .5.dp,
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
).background(
|
||||||
|
color = AppColors.Discover.Green.copy(alpha = .85f),
|
||||||
|
shape = CircleShape,
|
||||||
|
).size(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_check),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.border(
|
||||||
|
width = .5.dp,
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
).background(
|
||||||
|
color = AppColors.Discover.Green.copy(alpha = .85f),
|
||||||
|
shape = CircleShape,
|
||||||
|
).size(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(width = 10.dp, height = 2.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
private fun Preview() {
|
||||||
|
WholphinTheme {
|
||||||
|
Column {
|
||||||
|
PendingIndicator()
|
||||||
|
AvailableIndicator()
|
||||||
|
PartiallyAvailableIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,11 +16,9 @@ import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
|
|
@ -35,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -173,11 +173,12 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Genre(
|
data class Genre(
|
||||||
override val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val color: Color,
|
val color: Color,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
|
override val gridId: String get() = id.toString()
|
||||||
override val playable: Boolean = false
|
override val playable: Boolean = false
|
||||||
override val sortName: String get() = name
|
override val sortName: String get() = name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,10 +179,12 @@ fun ExpandablePlayButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
mirrorIcon: Boolean = false,
|
mirrorIcon: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
Button(
|
Button(
|
||||||
onClick = { onClick.invoke(resume) },
|
onClick = { onClick.invoke(resume) },
|
||||||
|
enabled = enabled,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.requiredSizeIn(
|
modifier.requiredSizeIn(
|
||||||
minWidth = MinButtonSize,
|
minWidth = MinButtonSize,
|
||||||
|
|
@ -234,6 +236,7 @@ fun ExpandableFaButton(
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.requiredSizeIn(
|
modifier.requiredSizeIn(
|
||||||
minWidth = MinButtonSize,
|
minWidth = MinButtonSize,
|
||||||
|
|
@ -242,7 +245,6 @@ fun ExpandableFaButton(
|
||||||
),
|
),
|
||||||
contentPadding = DefaultButtonPadding,
|
contentPadding = DefaultButtonPadding,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
enabled = enabled,
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
|
|
||||||
interface CardGridItem {
|
interface CardGridItem {
|
||||||
val id: UUID
|
val gridId: String
|
||||||
val playable: Boolean
|
val playable: Boolean
|
||||||
val sortName: String
|
val sortName: String
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +243,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
} else if (isPlayKeyUp(it)) {
|
} else if (isPlayKeyUp(it)) {
|
||||||
val item = pager.getOrNull(focusedIndex)
|
val item = pager.getOrNull(focusedIndex)
|
||||||
if (item?.playable == true) {
|
if (item?.playable == true) {
|
||||||
Timber.v("Clicked play on ${item.id}")
|
Timber.v("Clicked play on ${item.gridId}")
|
||||||
onClickPlay.invoke(focusedIndex, item)
|
onClickPlay.invoke(focusedIndex, item)
|
||||||
}
|
}
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
|
|
@ -375,7 +374,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
pager
|
pager
|
||||||
.getOrNull(focusedIndex)
|
.getOrNull(focusedIndex)
|
||||||
?.sortName
|
?.sortName
|
||||||
?.first()
|
?.firstOrNull()
|
||||||
?.uppercaseChar()
|
?.uppercaseChar()
|
||||||
?.let {
|
?.let {
|
||||||
if (it >= '0' && it <= '9') {
|
if (it >= '0' && it <= '9') {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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 com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
|
|
@ -25,7 +24,6 @@ import java.util.UUID
|
||||||
fun CollectionFolderBoxSet(
|
fun CollectionFolderBoxSet(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem?,
|
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ fun CollectionFolderMovie(
|
||||||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
val firstTabFocusRequester = remember { FocusRequester() }
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
LaunchedEffect(selectedTabIndex) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
logTab("movie", selectedTabIndex)
|
logTab("movie", selectedTabIndex)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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 com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||||
|
|
@ -22,7 +21,6 @@ import java.util.UUID
|
||||||
fun CollectionFolderPlaylist(
|
fun CollectionFolderPlaylist(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem?,
|
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ fun buildMoreDialogItems(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
item.id,
|
item.id,
|
||||||
item.resumeMs ?: 0L,
|
item.resumeMs ?: 0L,
|
||||||
item,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -166,6 +165,7 @@ fun buildMoreDialogItems(
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
seriesId,
|
seriesId,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -200,7 +200,6 @@ fun buildMoreDialogItems(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
item.id,
|
item.id,
|
||||||
item.resumeMs ?: 0L,
|
item.resumeMs ?: 0L,
|
||||||
item,
|
|
||||||
forceTranscoding = true,
|
forceTranscoding = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome(
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
it,
|
it,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
|
||||||
context.getString(R.string.go_to),
|
context.getString(R.string.go_to),
|
||||||
Icons.Default.ArrowForward,
|
Icons.Default.ArrowForward,
|
||||||
) {
|
) {
|
||||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
|
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
|
|
@ -57,6 +60,8 @@ import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.formatDate
|
import com.github.damontecres.wholphin.ui.formatDate
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -67,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
@ -90,10 +98,12 @@ class PersonViewModel
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
) : LoadingItemViewModel(api) {
|
) : LoadingItemViewModel(api) {
|
||||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(itemId: UUID) {
|
||||||
viewModelScope.launchIO(
|
viewModelScope.launchIO(
|
||||||
|
|
@ -119,6 +129,10 @@ class PersonViewModel
|
||||||
} else {
|
} else {
|
||||||
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val results = seerrService.similar(person).orEmpty()
|
||||||
|
discovered.update { results }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +195,7 @@ fun PersonPage(
|
||||||
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
||||||
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
||||||
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
||||||
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
|
|
@ -219,6 +234,10 @@ fun PersonPage(
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
viewModel.setFavorite(!person.favorite)
|
viewModel.setFavorite(!person.favorite)
|
||||||
},
|
},
|
||||||
|
discovered = discovered,
|
||||||
|
onClickDiscover = { index, item ->
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination)
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
AnimatedVisibility(showOverviewDialog) {
|
AnimatedVisibility(showOverviewDialog) {
|
||||||
|
|
@ -243,6 +262,7 @@ private const val HEADER_ROW = 0
|
||||||
private const val MOVIE_ROW = 1
|
private const val MOVIE_ROW = 1
|
||||||
private const val SERIES_ROW = 2
|
private const val SERIES_ROW = 2
|
||||||
private const val EPISODE_ROW = 3
|
private const val EPISODE_ROW = 3
|
||||||
|
private const val DISCOVER_ROW = EPISODE_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonPageContent(
|
fun PersonPageContent(
|
||||||
|
|
@ -257,9 +277,11 @@ fun PersonPageContent(
|
||||||
movies: RowLoadingState,
|
movies: RowLoadingState,
|
||||||
series: RowLoadingState,
|
series: RowLoadingState,
|
||||||
episodes: RowLoadingState,
|
episodes: RowLoadingState,
|
||||||
|
discovered: List<DiscoverItem>,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -359,6 +381,24 @@ fun PersonPageContent(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (discovered.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DiscoverRow(
|
||||||
|
row =
|
||||||
|
DiscoverRowData(
|
||||||
|
stringResource(R.string.discover),
|
||||||
|
DataLoadingState.Success(discovered),
|
||||||
|
),
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
position = RowColumn(DISCOVER_ROW, index)
|
||||||
|
onClickDiscover.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { _, _ -> },
|
||||||
|
onCardFocus = {},
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||||
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverMovieDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination.DiscoveredItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DiscoverMovieViewModel =
|
||||||
|
hiltViewModel<DiscoverMovieViewModel, DiscoverMovieViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination.item) },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
viewModel.init()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
val item by viewModel.movie.observeAsState()
|
||||||
|
val rating by viewModel.rating.observeAsState(null)
|
||||||
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
|
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||||
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
|
val recommended by viewModel.similar.observeAsState(listOf())
|
||||||
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||||
|
|
||||||
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
|
val moreActions =
|
||||||
|
MoreDialogActions(
|
||||||
|
navigateTo = viewModel::navigateTo,
|
||||||
|
onClickWatch = { itemId, watched -> },
|
||||||
|
onClickFavorite = { itemId, favorite -> },
|
||||||
|
onClickAddPlaylist = { itemId -> },
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val state = loading) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
item?.let { movie ->
|
||||||
|
DiscoverMovieDetailsContent(
|
||||||
|
preferences = preferences,
|
||||||
|
movie = movie,
|
||||||
|
userConfig = userConfig,
|
||||||
|
rating = rating,
|
||||||
|
people = people,
|
||||||
|
trailers = trailers,
|
||||||
|
similar = similar,
|
||||||
|
recommended = recommended,
|
||||||
|
requestOnClick = {
|
||||||
|
movie.id?.let { viewModel.request(it) }
|
||||||
|
},
|
||||||
|
cancelOnClick = {
|
||||||
|
movie.id?.let { viewModel.cancelRequest(it) }
|
||||||
|
},
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||||
|
},
|
||||||
|
onClickPerson = { item ->
|
||||||
|
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||||
|
},
|
||||||
|
overviewOnClick = {
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = movie.title ?: context.getString(R.string.unknown),
|
||||||
|
overview = movie.overview,
|
||||||
|
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
|
||||||
|
files = listOf(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
goToOnClick = {
|
||||||
|
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||||
|
viewModel.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = it,
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
moreOnClick = {
|
||||||
|
moreDialog =
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||||
|
items = listOf(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLongClickPerson = { index, person -> },
|
||||||
|
onLongClickSimilar = { index, similar ->
|
||||||
|
},
|
||||||
|
trailerOnClick = {
|
||||||
|
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overviewDialog?.let { info ->
|
||||||
|
ItemDetailsDialog(
|
||||||
|
info = info,
|
||||||
|
showFilePath = false,
|
||||||
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { moreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER_ROW = 0
|
||||||
|
private const val PEOPLE_ROW = HEADER_ROW + 1
|
||||||
|
private const val CHAPTER_ROW = PEOPLE_ROW + 1
|
||||||
|
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
||||||
|
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
|
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverMovieDetailsContent(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
userConfig: SeerrUserConfig?,
|
||||||
|
movie: MovieDetails,
|
||||||
|
rating: DiscoverRating?,
|
||||||
|
people: List<DiscoverItem>,
|
||||||
|
trailers: List<Trailer>,
|
||||||
|
similar: List<DiscoverItem>,
|
||||||
|
recommended: List<DiscoverItem>,
|
||||||
|
requestOnClick: () -> Unit,
|
||||||
|
cancelOnClick: () -> Unit,
|
||||||
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
goToOnClick: () -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||||
|
onClickPerson: (DiscoverItem) -> Unit,
|
||||||
|
onLongClickPerson: (Int, DiscoverItem) -> Unit,
|
||||||
|
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var position by rememberInt(0)
|
||||||
|
val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
|
||||||
|
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
) {
|
||||||
|
DiscoverMovieDetailsHeader(
|
||||||
|
preferences = preferences,
|
||||||
|
movie = movie,
|
||||||
|
rating = rating,
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
overviewOnClick = overviewOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 32.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
val canCancel =
|
||||||
|
remember(movie, userConfig) {
|
||||||
|
(
|
||||||
|
// User requested this
|
||||||
|
userConfig.hasPermission(SeerrPermission.REQUEST) &&
|
||||||
|
movie.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true
|
||||||
|
) ||
|
||||||
|
userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS)
|
||||||
|
}
|
||||||
|
ExpandableDiscoverButtons(
|
||||||
|
availability =
|
||||||
|
SeerrAvailability.from(movie.mediaInfo?.status)
|
||||||
|
?: SeerrAvailability.UNKNOWN,
|
||||||
|
requestOnClick = requestOnClick,
|
||||||
|
cancelOnClick = cancelOnClick,
|
||||||
|
moreOnClick = moreOnClick,
|
||||||
|
goToOnClick = goToOnClick,
|
||||||
|
buttonOnFocusChanged = {
|
||||||
|
if (it.isFocused) {
|
||||||
|
position = HEADER_ROW
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||||
|
canCancel = canCancel,
|
||||||
|
trailers = trailers,
|
||||||
|
trailerOnClick = trailerOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.focusRequester(focusRequesters[HEADER_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (people.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DiscoverRow(
|
||||||
|
row =
|
||||||
|
DiscoverRowData(
|
||||||
|
stringResource(R.string.people),
|
||||||
|
DataLoadingState.Success(people),
|
||||||
|
),
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
position = PEOPLE_ROW
|
||||||
|
onClickPerson.invoke(item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, person ->
|
||||||
|
position = PEOPLE_ROW
|
||||||
|
onLongClickPerson.invoke(index, person)
|
||||||
|
},
|
||||||
|
onCardFocus = {},
|
||||||
|
focusRequester = focusRequesters[PEOPLE_ROW],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similar.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.more_like_this),
|
||||||
|
items = similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = SIMILAR_ROW
|
||||||
|
onClickItem.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, similar ->
|
||||||
|
position = SIMILAR_ROW
|
||||||
|
onLongClickSimilar.invoke(index, similar)
|
||||||
|
},
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = false,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (recommended.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.recommended),
|
||||||
|
items = similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RECOMMENDED_ROW
|
||||||
|
onClickItem.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, similar ->
|
||||||
|
position = RECOMMENDED_ROW
|
||||||
|
onLongClickSimilar.invoke(index, similar)
|
||||||
|
},
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = true,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TrailerRow(
|
||||||
|
trailers: List<Trailer>,
|
||||||
|
onClickTrailer: (Trailer) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.trailers),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
state = state,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
itemsIndexed(trailers) { index, item ->
|
||||||
|
val cardModifier =
|
||||||
|
if (index == 0) {
|
||||||
|
Modifier.focusRequester(firstFocus)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
when (item) {
|
||||||
|
is LocalTrailer -> {
|
||||||
|
SeasonCard(
|
||||||
|
item = item.baseItem,
|
||||||
|
onClick = { onClickTrailer.invoke(item) },
|
||||||
|
onLongClick = {},
|
||||||
|
imageHeight = Cards.height2x3,
|
||||||
|
imageWidth = Dp.Unspecified,
|
||||||
|
showImageOverlay = false,
|
||||||
|
modifier = cardModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemoteTrailer -> {
|
||||||
|
val subtitle =
|
||||||
|
when (item.url.toUri().host) {
|
||||||
|
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
SeasonCard(
|
||||||
|
title = item.name,
|
||||||
|
subtitle = subtitle,
|
||||||
|
name = item.name,
|
||||||
|
imageUrl = null,
|
||||||
|
isFavorite = false,
|
||||||
|
isPlayed = false,
|
||||||
|
unplayedItemCount = 0,
|
||||||
|
playedPercentage = 0.0,
|
||||||
|
numberOfVersions = -1,
|
||||||
|
onClick = { onClickTrailer.invoke(item) },
|
||||||
|
onLongClick = {},
|
||||||
|
modifier = cardModifier,
|
||||||
|
showImageOverlay = false,
|
||||||
|
imageHeight = Cards.height2x3,
|
||||||
|
imageWidth = Dp.Unspecified,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
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.api.seerr.model.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverMovieDetailsHeader(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
movie: MovieDetails,
|
||||||
|
rating: DiscoverRating?,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = movie.title ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
|
) {
|
||||||
|
val padding = 4.dp
|
||||||
|
val details =
|
||||||
|
remember(movie) {
|
||||||
|
buildList {
|
||||||
|
movie.releaseDate?.let(::add)
|
||||||
|
movie.runtime
|
||||||
|
?.toDouble()
|
||||||
|
?.minutes
|
||||||
|
?.roundMinutes
|
||||||
|
?.toString()
|
||||||
|
?.let(::add)
|
||||||
|
val release =
|
||||||
|
movie.releases
|
||||||
|
?.results
|
||||||
|
?.firstOrNull { it.iso31661 == Locale.getDefault().country }
|
||||||
|
?: movie.releases
|
||||||
|
?.results
|
||||||
|
?.firstOrNull { it.iso31661 == Locale.US.country }
|
||||||
|
?: movie.releases
|
||||||
|
?.results
|
||||||
|
?.firstOrNull()
|
||||||
|
|
||||||
|
release
|
||||||
|
?.releaseDates
|
||||||
|
?.firstOrNull()
|
||||||
|
?.certification
|
||||||
|
?.let(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DotSeparatedRow(
|
||||||
|
texts = details,
|
||||||
|
communityRating = rating?.audienceRating,
|
||||||
|
criticRating = rating?.criticRating?.toFloat(),
|
||||||
|
textStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||||
|
GenreText(it, Modifier.padding(bottom = padding))
|
||||||
|
}
|
||||||
|
|
||||||
|
movie.tagline?.let { tagline ->
|
||||||
|
Text(
|
||||||
|
text = tagline,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
movie.overview?.let { overview ->
|
||||||
|
OverviewText(
|
||||||
|
overview = overview,
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
textBoxHeight = Dp.Unspecified,
|
||||||
|
modifier =
|
||||||
|
Modifier.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val directorName =
|
||||||
|
remember(movie.credits?.crew) {
|
||||||
|
movie.credits
|
||||||
|
?.crew
|
||||||
|
?.filter { it.job == "Directing" }
|
||||||
|
?.joinToString(", ") { it.name!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
directorName
|
||||||
|
?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.directed_by, it),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
||||||
|
class DiscoverMovieViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val seerrService: SeerrService,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
@Assisted val item: DiscoverItem,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(item: DiscoverItem): DiscoverMovieViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
val movie = MutableLiveData<MovieDetails?>(null)
|
||||||
|
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||||
|
|
||||||
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
|
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndSetItem(): Deferred<MovieDetails> =
|
||||||
|
viewModelScope.async(
|
||||||
|
Dispatchers.IO +
|
||||||
|
LoadingExceptionHandler(
|
||||||
|
loading,
|
||||||
|
"Error fetching movie",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||||
|
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
|
||||||
|
movie
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(): Job =
|
||||||
|
viewModelScope.launch(
|
||||||
|
Dispatchers.IO +
|
||||||
|
LoadingExceptionHandler(
|
||||||
|
loading,
|
||||||
|
"Error fetching movie",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val movie = fetchAndSetItem().await()
|
||||||
|
val discoveredItem = DiscoverItem(movie)
|
||||||
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loading.value = LoadingState.Success
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
||||||
|
rating.setValueOnMain(DiscoverRating(result))
|
||||||
|
}
|
||||||
|
if (!similar.isInitialized) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
seerrService.api.moviesApi
|
||||||
|
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
similar.setValueOnMain(result)
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
seerrService.api.moviesApi
|
||||||
|
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
similar.setValueOnMain(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val people =
|
||||||
|
movie.credits
|
||||||
|
?.cast
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty() +
|
||||||
|
movie.credits
|
||||||
|
?.crew
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
this@DiscoverMovieViewModel.people.setValueOnMain(people)
|
||||||
|
val trailers =
|
||||||
|
movie.relatedVideos
|
||||||
|
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||||
|
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||||
|
?.map {
|
||||||
|
RemoteTrailer(it.name!!, it.url!!, null)
|
||||||
|
}.orEmpty()
|
||||||
|
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateTo(destination: Destination) {
|
||||||
|
navigationManager.navigateTo(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request(id: Int) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val request =
|
||||||
|
seerrService.api.requestApi.requestPost(
|
||||||
|
RequestPostRequest(
|
||||||
|
is4k = false,
|
||||||
|
mediaId = id,
|
||||||
|
mediaType = RequestPostRequest.MediaType.MOVIE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fetchAndSetItem().await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelRequest(id: Int) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||||
|
// TODO handle multiple requests? Or just delete self's request?
|
||||||
|
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||||
|
fetchAndSetItem().await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class)
|
||||||
|
class DiscoverPersonViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val seerrService: SeerrService,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
@Assisted val item: DiscoverItem,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(item: DiscoverItem): DiscoverPersonViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val credits = MutableStateFlow<DataLoadingState<List<DiscoverItem>>>(DataLoadingState.Pending)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
|
||||||
|
val credits =
|
||||||
|
seerrService.api.personApi
|
||||||
|
.personPersonIdCombinedCreditsGet(personId = item.id)
|
||||||
|
.let { credits ->
|
||||||
|
val cast =
|
||||||
|
credits.cast
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
val crew =
|
||||||
|
credits.crew
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
cast + crew
|
||||||
|
}
|
||||||
|
this@DiscoverPersonViewModel.credits.update {
|
||||||
|
DataLoadingState.Success(credits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverPersonPage(
|
||||||
|
person: DiscoverItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DiscoverPersonViewModel =
|
||||||
|
hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(person) },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val credits by viewModel.credits.collectAsState()
|
||||||
|
|
||||||
|
when (val state = credits) {
|
||||||
|
is DataLoadingState.Error -> {
|
||||||
|
ErrorMessage(state.message, state.exception, modifier.focusable())
|
||||||
|
}
|
||||||
|
|
||||||
|
DataLoadingState.Loading,
|
||||||
|
DataLoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier.focusable())
|
||||||
|
}
|
||||||
|
|
||||||
|
is DataLoadingState.Success<List<DiscoverItem>> -> {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (state.data.isNotEmpty()) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_results),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardGrid(
|
||||||
|
pager = state.data,
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
|
||||||
|
},
|
||||||
|
onLongClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
},
|
||||||
|
onClickPlay = { _, item ->
|
||||||
|
},
|
||||||
|
letterPosition = { c: Char -> 0 },
|
||||||
|
gridFocusRequester = focusRequester,
|
||||||
|
showJumpButtons = false,
|
||||||
|
showLetterButtons = false,
|
||||||
|
spacing = 16.dp,
|
||||||
|
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = true,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
columns = 6,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverQuickDetails(
|
||||||
|
item: DiscoverItem?,
|
||||||
|
rating: DiscoverRating?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Timber.v("id=${item?.id}, rating=$rating")
|
||||||
|
val context = LocalContext.current
|
||||||
|
val details =
|
||||||
|
remember(item) {
|
||||||
|
buildList {
|
||||||
|
item
|
||||||
|
?.releaseDate
|
||||||
|
?.year
|
||||||
|
?.toString()
|
||||||
|
?.let(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DotSeparatedRow(
|
||||||
|
texts = details,
|
||||||
|
communityRating = rating?.audienceRating,
|
||||||
|
criticRating = rating?.criticRating?.toFloat(),
|
||||||
|
textStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,527 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||||
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverSeriesDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination.DiscoveredItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DiscoverSeriesViewModel =
|
||||||
|
hiltViewModel<DiscoverSeriesViewModel, DiscoverSeriesViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination.item) },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
|
val item by viewModel.tvSeries.observeAsState()
|
||||||
|
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||||
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
|
val recommended by viewModel.recommended.observeAsState(listOf())
|
||||||
|
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||||
|
|
||||||
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
|
when (val state = loading) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
ErrorMessage(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
item?.let { item ->
|
||||||
|
val rating by viewModel.rating.observeAsState(null)
|
||||||
|
DiscoverSeriesDetailsContent(
|
||||||
|
preferences = preferences,
|
||||||
|
series = item,
|
||||||
|
userConfig = userConfig,
|
||||||
|
rating = rating,
|
||||||
|
seasons = seasons,
|
||||||
|
people = people,
|
||||||
|
similar = similar,
|
||||||
|
recommended = recommended,
|
||||||
|
modifier = modifier,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||||
|
},
|
||||||
|
onClickPerson = {
|
||||||
|
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||||
|
},
|
||||||
|
goToOnClick = {
|
||||||
|
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||||
|
viewModel.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = it,
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overviewOnClick = {
|
||||||
|
overviewDialog =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = item.name ?: context.getString(R.string.unknown),
|
||||||
|
overview = item.overview,
|
||||||
|
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
|
||||||
|
files = listOf(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailerOnClick = {
|
||||||
|
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||||
|
},
|
||||||
|
trailers = listOf(),
|
||||||
|
requestOnClick = {
|
||||||
|
item.id?.let { viewModel.request(it) }
|
||||||
|
},
|
||||||
|
cancelOnClick = {
|
||||||
|
item.id?.let { viewModel.cancelRequest(it) }
|
||||||
|
},
|
||||||
|
moreOnClick = {
|
||||||
|
},
|
||||||
|
onLongClickPerson = { _, _ -> },
|
||||||
|
onLongClickSimilar = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overviewDialog?.let { info ->
|
||||||
|
ItemDetailsDialog(
|
||||||
|
info = info,
|
||||||
|
showFilePath = false,
|
||||||
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seasonDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
onDismissRequest = { seasonDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER_ROW = 0
|
||||||
|
private const val SEASONS_ROW = HEADER_ROW + 1
|
||||||
|
private const val PEOPLE_ROW = SEASONS_ROW + 1
|
||||||
|
private const val EXTRAS_ROW = PEOPLE_ROW + 1
|
||||||
|
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
|
private const val RECOMMENDED_ROW = SIMILAR_ROW + 1
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverSeriesDetailsContent(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
userConfig: SeerrUserConfig?,
|
||||||
|
series: TvDetails,
|
||||||
|
rating: DiscoverRating?,
|
||||||
|
seasons: List<Season>,
|
||||||
|
similar: List<DiscoverItem>,
|
||||||
|
recommended: List<DiscoverItem>,
|
||||||
|
trailers: List<Trailer>,
|
||||||
|
people: List<DiscoverItem>,
|
||||||
|
requestOnClick: () -> Unit,
|
||||||
|
cancelOnClick: () -> Unit,
|
||||||
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
goToOnClick: () -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||||
|
onClickPerson: (DiscoverItem) -> Unit,
|
||||||
|
onLongClickPerson: (Int, DiscoverItem) -> Unit,
|
||||||
|
onLongClickSimilar: (Int, DiscoverItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
|
||||||
|
var position by rememberInt()
|
||||||
|
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(bottom = 80.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
) {
|
||||||
|
DiscoverSeriesDetailsHeader(
|
||||||
|
series = series,
|
||||||
|
rating = rating,
|
||||||
|
overviewOnClick = overviewOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 32.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
val canCancel =
|
||||||
|
remember(series, userConfig) {
|
||||||
|
(
|
||||||
|
// User requested this
|
||||||
|
userConfig.hasPermission(SeerrPermission.REQUEST) &&
|
||||||
|
series.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true
|
||||||
|
) ||
|
||||||
|
userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS)
|
||||||
|
}
|
||||||
|
ExpandableDiscoverButtons(
|
||||||
|
availability =
|
||||||
|
SeerrAvailability.from(series.mediaInfo?.status)
|
||||||
|
?: SeerrAvailability.UNKNOWN,
|
||||||
|
requestOnClick = requestOnClick,
|
||||||
|
cancelOnClick = cancelOnClick,
|
||||||
|
moreOnClick = moreOnClick,
|
||||||
|
goToOnClick = goToOnClick,
|
||||||
|
buttonOnFocusChanged = {
|
||||||
|
if (it.isFocused) {
|
||||||
|
position = HEADER_ROW
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
|
||||||
|
canCancel = canCancel,
|
||||||
|
trailers = trailers,
|
||||||
|
trailerOnClick = trailerOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.focusRequester(focusRequesters[HEADER_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// item {
|
||||||
|
// ItemRow(
|
||||||
|
// title = stringResource(R.string.tv_seasons) + " (${seasons.size})",
|
||||||
|
// items = seasons,
|
||||||
|
// onClickItem = { index, item ->
|
||||||
|
// position = SEASONS_ROW
|
||||||
|
// // onClickItem.invoke(index, item)
|
||||||
|
// },
|
||||||
|
// onLongClickItem = { index, item ->
|
||||||
|
// position = SEASONS_ROW
|
||||||
|
// },
|
||||||
|
// modifier =
|
||||||
|
// Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .focusRequester(focusRequesters[SEASONS_ROW]),
|
||||||
|
// cardContent = @Composable { index, item, mod, onClick, onLongClick ->
|
||||||
|
// SeasonCard(
|
||||||
|
// item = item,
|
||||||
|
// onClick = onClick,
|
||||||
|
// onLongClick = onLongClick,
|
||||||
|
// imageHeight = Cards.height2x3,
|
||||||
|
// imageWidth = Dp.Unspecified,
|
||||||
|
// showImageOverlay = true,
|
||||||
|
// modifier = mod,
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
if (people.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DiscoverRow(
|
||||||
|
row =
|
||||||
|
DiscoverRowData(
|
||||||
|
stringResource(R.string.people),
|
||||||
|
DataLoadingState.Success(people),
|
||||||
|
),
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
position = PEOPLE_ROW
|
||||||
|
onClickPerson.invoke(item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, person ->
|
||||||
|
position = PEOPLE_ROW
|
||||||
|
onLongClickPerson.invoke(index, person)
|
||||||
|
},
|
||||||
|
onCardFocus = {},
|
||||||
|
focusRequester = focusRequesters[PEOPLE_ROW],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (similar.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.more_like_this),
|
||||||
|
items = similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = SIMILAR_ROW
|
||||||
|
onClickItem.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, similar ->
|
||||||
|
position = SIMILAR_ROW
|
||||||
|
onLongClickSimilar.invoke(index, similar)
|
||||||
|
},
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = false,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequesters[SIMILAR_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (recommended.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
ItemRow(
|
||||||
|
title = stringResource(R.string.recommended),
|
||||||
|
items = similar,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RECOMMENDED_ROW
|
||||||
|
onClickItem.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, similar ->
|
||||||
|
position = RECOMMENDED_ROW
|
||||||
|
onLongClickSimilar.invoke(index, similar)
|
||||||
|
},
|
||||||
|
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = true,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequesters[RECOMMENDED_ROW]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moreDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = { moreDialog = null },
|
||||||
|
dismissOnClick = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverSeriesDetailsHeader(
|
||||||
|
series: TvDetails,
|
||||||
|
rating: DiscoverRating?,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = series.name ?: stringResource(R.string.unknown),
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
|
) {
|
||||||
|
val padding = 4.dp
|
||||||
|
val details =
|
||||||
|
remember(series) {
|
||||||
|
buildList {
|
||||||
|
series.firstAirDate?.let(::add)
|
||||||
|
series.episodeRunTime
|
||||||
|
?.average()
|
||||||
|
?.takeIf { !it.isNaN() && it > 0 }
|
||||||
|
?.minutes
|
||||||
|
?.roundMinutes
|
||||||
|
?.toString()
|
||||||
|
?.let(::add)
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DotSeparatedRow(
|
||||||
|
texts = details,
|
||||||
|
communityRating = rating?.audienceRating,
|
||||||
|
criticRating = rating?.criticRating?.toFloat(),
|
||||||
|
textStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
series.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||||
|
GenreText(it, Modifier.padding(bottom = padding))
|
||||||
|
}
|
||||||
|
series.overview?.let { overview ->
|
||||||
|
OverviewText(
|
||||||
|
overview = overview,
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
textBoxHeight = Dp.Unspecified,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDialogForSeason(
|
||||||
|
context: Context,
|
||||||
|
s: BaseItem,
|
||||||
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
markPlayed: (Boolean) -> Unit,
|
||||||
|
onClickPlay: (Boolean) -> Unit,
|
||||||
|
): DialogParams {
|
||||||
|
val items =
|
||||||
|
buildList {
|
||||||
|
add(
|
||||||
|
DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) {
|
||||||
|
onClickItem.invoke(s)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (s.data.userData?.played == true) {
|
||||||
|
add(
|
||||||
|
DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) {
|
||||||
|
markPlayed.invoke(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
add(
|
||||||
|
DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) {
|
||||||
|
markPlayed.invoke(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.play),
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
onClickPlay.invoke(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.shuffle),
|
||||||
|
R.string.fa_shuffle,
|
||||||
|
) {
|
||||||
|
onClickPlay.invoke(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DialogParams(
|
||||||
|
title = s.name ?: context.getString(R.string.tv_season),
|
||||||
|
fromLongClick = true,
|
||||||
|
items = items,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
|
||||||
|
class DiscoverSeriesViewModel
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val seerrService: SeerrService,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
@Assisted val item: DiscoverItem,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(item: DiscoverItem): DiscoverSeriesViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
val tvSeries = MutableLiveData<TvDetails?>(null)
|
||||||
|
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||||
|
|
||||||
|
val seasons = MutableLiveData<List<Season>>(listOf())
|
||||||
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
|
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndSetItem(): Deferred<TvDetails> =
|
||||||
|
viewModelScope.async(
|
||||||
|
Dispatchers.IO +
|
||||||
|
LoadingExceptionHandler(
|
||||||
|
loading,
|
||||||
|
"Error fetching movie",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||||
|
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
|
||||||
|
tv
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(): Job =
|
||||||
|
viewModelScope.launch(
|
||||||
|
Dispatchers.IO +
|
||||||
|
LoadingExceptionHandler(
|
||||||
|
loading,
|
||||||
|
"Error fetching movie",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val tv = fetchAndSetItem().await()
|
||||||
|
val discoveredItem = DiscoverItem(tv)
|
||||||
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loading.value = LoadingState.Success
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
||||||
|
rating.setValueOnMain(DiscoverRating(result))
|
||||||
|
}
|
||||||
|
if (!similar.isInitialized) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
seerrService.api.moviesApi
|
||||||
|
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
similar.setValueOnMain(result)
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val result =
|
||||||
|
seerrService.api.moviesApi
|
||||||
|
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||||
|
.results
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
similar.setValueOnMain(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val people =
|
||||||
|
tv.credits
|
||||||
|
?.cast
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty() +
|
||||||
|
tv.credits
|
||||||
|
?.crew
|
||||||
|
?.map(::DiscoverItem)
|
||||||
|
.orEmpty()
|
||||||
|
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateTo(destination: Destination) {
|
||||||
|
navigationManager.navigateTo(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request(id: Int) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val request =
|
||||||
|
seerrService.api.requestApi.requestPost(
|
||||||
|
RequestPostRequest(
|
||||||
|
is4k = false,
|
||||||
|
mediaId = id,
|
||||||
|
mediaType = RequestPostRequest.MediaType.TV,
|
||||||
|
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fetchAndSetItem().await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelRequest(id: Int) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||||
|
// TODO handle multiple requests? Or just delete self's request?
|
||||||
|
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||||
|
fetchAndSetItem().await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusState
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandableDiscoverButtons(
|
||||||
|
canRequest: Boolean,
|
||||||
|
canCancel: Boolean,
|
||||||
|
availability: SeerrAvailability,
|
||||||
|
trailers: List<Trailer>?,
|
||||||
|
requestOnClick: () -> Unit,
|
||||||
|
cancelOnClick: () -> Unit,
|
||||||
|
goToOnClick: () -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
when (availability) {
|
||||||
|
SeerrAvailability.UNKNOWN -> R.string.request
|
||||||
|
|
||||||
|
SeerrAvailability.PENDING,
|
||||||
|
SeerrAvailability.PROCESSING,
|
||||||
|
-> R.string.pending
|
||||||
|
|
||||||
|
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||||
|
SeerrAvailability.AVAILABLE,
|
||||||
|
-> R.string.go_to
|
||||||
|
|
||||||
|
SeerrAvailability.DELETED -> R.string.delete // TODO
|
||||||
|
}
|
||||||
|
val icon =
|
||||||
|
when (availability) {
|
||||||
|
SeerrAvailability.UNKNOWN -> R.string.fa_download
|
||||||
|
|
||||||
|
SeerrAvailability.PENDING,
|
||||||
|
SeerrAvailability.PROCESSING,
|
||||||
|
-> R.string.fa_clock
|
||||||
|
|
||||||
|
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||||
|
SeerrAvailability.AVAILABLE,
|
||||||
|
-> R.string.fa_play
|
||||||
|
|
||||||
|
SeerrAvailability.DELETED -> R.string.fa_video // TODO
|
||||||
|
}
|
||||||
|
item("first") {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = text,
|
||||||
|
iconStringRes = icon,
|
||||||
|
enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true,
|
||||||
|
onClick = {
|
||||||
|
when (availability) {
|
||||||
|
SeerrAvailability.UNKNOWN -> {
|
||||||
|
requestOnClick.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.PENDING,
|
||||||
|
SeerrAvailability.PROCESSING,
|
||||||
|
-> {
|
||||||
|
// TODO?
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||||
|
SeerrAvailability.AVAILABLE,
|
||||||
|
-> {
|
||||||
|
goToOnClick.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.DELETED -> {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(firstFocus)
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canCancel) {
|
||||||
|
item("cancel") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.cancel,
|
||||||
|
icon = Icons.Default.Delete,
|
||||||
|
onClick = {
|
||||||
|
firstFocus.tryRequestFocus()
|
||||||
|
cancelOnClick.invoke()
|
||||||
|
},
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
enabled = canCancel,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trailers != null) {
|
||||||
|
item("trailers") {
|
||||||
|
TrailerButton(
|
||||||
|
trailers = trailers,
|
||||||
|
trailerOnClick = trailerOnClick,
|
||||||
|
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// More button
|
||||||
|
item("more") {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
R.string.more,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.MoreVert,
|
||||||
|
{ moreOnClick.invoke() },
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,6 @@ fun EpisodeDetails(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
ep.id,
|
ep.id,
|
||||||
it.inWholeMilliseconds,
|
it.inWholeMilliseconds,
|
||||||
ep,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
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.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
|
||||||
|
|
@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -99,6 +104,7 @@ fun MovieDetails(
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||||
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -168,7 +174,6 @@ fun MovieDetails(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
movie.id,
|
movie.id,
|
||||||
it.inWholeMilliseconds,
|
it.inWholeMilliseconds,
|
||||||
movie,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -300,6 +305,10 @@ fun MovieDetails(
|
||||||
onClickExtra = { index, extra ->
|
onClickExtra = { index, extra ->
|
||||||
viewModel.navigateTo(extra.destination)
|
viewModel.navigateTo(extra.destination)
|
||||||
},
|
},
|
||||||
|
discovered = discovered,
|
||||||
|
onClickDiscover = { index, item ->
|
||||||
|
viewModel.navigateTo(item.destination)
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -360,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1
|
||||||
private const val CHAPTER_ROW = TRAILER_ROW + 1
|
private const val CHAPTER_ROW = TRAILER_ROW + 1
|
||||||
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
private const val EXTRAS_ROW = CHAPTER_ROW + 1
|
||||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
|
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieDetailsContent(
|
fun MovieDetailsContent(
|
||||||
|
|
@ -371,6 +381,7 @@ fun MovieDetailsContent(
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
similar: List<BaseItem>,
|
similar: List<BaseItem>,
|
||||||
|
discovered: List<DiscoverItem>,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
|
|
@ -382,12 +393,13 @@ fun MovieDetailsContent(
|
||||||
onLongClickPerson: (Int, Person) -> Unit,
|
onLongClickPerson: (Int, Person) -> Unit,
|
||||||
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var position by rememberInt(0)
|
var position by rememberInt(0)
|
||||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||||
val dto = movie.data
|
val dto = movie.data
|
||||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
|
||||||
|
|
@ -543,6 +555,24 @@ fun MovieDetailsContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (discovered.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DiscoverRow(
|
||||||
|
row =
|
||||||
|
DiscoverRowData(
|
||||||
|
stringResource(R.string.discover),
|
||||||
|
DataLoadingState.Success(discovered),
|
||||||
|
),
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
position = DISCOVER_ROW
|
||||||
|
onClickDiscover.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { _, _ -> },
|
||||||
|
onCardFocus = {},
|
||||||
|
focusRequester = focusRequesters[DISCOVER_ROW],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
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.Chapter
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
|
@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
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.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
|
@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
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
|
||||||
|
|
@ -54,6 +58,7 @@ class MovieViewModel
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
|
|
@ -81,6 +86,7 @@ class MovieViewModel
|
||||||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||||
val similar = MutableLiveData<List<BaseItem>>()
|
val similar = MutableLiveData<List<BaseItem>>()
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
|
|
@ -140,6 +146,10 @@ class MovieViewModel
|
||||||
val extras = extrasService.getExtras(item.id)
|
val extras = extrasService.getExtras(item.id)
|
||||||
this@MovieViewModel.extras.setValueOnMain(extras)
|
this@MovieViewModel.extras.setValueOnMain(extras)
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val results = seerrService.similar(item).orEmpty()
|
||||||
|
discovered.update { results }
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chapters.value = Chapter.fromDto(item.data, api)
|
chapters.value = Chapter.fromDto(item.data, api)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -40,6 +41,7 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
|
@ -71,9 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -104,6 +109,7 @@ fun SeriesDetails(
|
||||||
val extras by viewModel.extras.observeAsState(listOf())
|
val extras by viewModel.extras.observeAsState(listOf())
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||||
|
|
@ -208,6 +214,10 @@ fun SeriesDetails(
|
||||||
onClickExtra = { _, extra ->
|
onClickExtra = { _, extra ->
|
||||||
viewModel.navigateTo(extra.destination)
|
viewModel.navigateTo(extra.destination)
|
||||||
},
|
},
|
||||||
|
discovered = discovered,
|
||||||
|
onClickDiscover = { index, item ->
|
||||||
|
viewModel.navigateTo(item.destination)
|
||||||
|
},
|
||||||
moreActions =
|
moreActions =
|
||||||
MoreDialogActions(
|
MoreDialogActions(
|
||||||
navigateTo = { viewModel.navigateTo(it) },
|
navigateTo = { viewModel.navigateTo(it) },
|
||||||
|
|
@ -281,6 +291,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1
|
||||||
private const val TRAILER_ROW = PEOPLE_ROW + 1
|
private const val TRAILER_ROW = PEOPLE_ROW + 1
|
||||||
private const val EXTRAS_ROW = TRAILER_ROW + 1
|
private const val EXTRAS_ROW = TRAILER_ROW + 1
|
||||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
|
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesDetailsContent(
|
fun SeriesDetailsContent(
|
||||||
|
|
@ -291,6 +302,7 @@ fun SeriesDetailsContent(
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
people: List<Person>,
|
people: List<Person>,
|
||||||
|
discovered: List<DiscoverItem>,
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
|
@ -303,6 +315,7 @@ fun SeriesDetailsContent(
|
||||||
trailerOnClick: (Trailer) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||||
moreActions: MoreDialogActions,
|
moreActions: MoreDialogActions,
|
||||||
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -310,7 +323,7 @@ fun SeriesDetailsContent(
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
|
||||||
var position by rememberInt()
|
var position by rememberInt()
|
||||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -546,6 +559,24 @@ fun SeriesDetailsContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (discovered.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DiscoverRow(
|
||||||
|
row =
|
||||||
|
DiscoverRowData(
|
||||||
|
stringResource(R.string.discover),
|
||||||
|
DataLoadingState.Success(discovered),
|
||||||
|
),
|
||||||
|
onClickItem = { index: Int, item: DiscoverItem ->
|
||||||
|
position = DISCOVER_ROW
|
||||||
|
onClickDiscover.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { _, _ -> },
|
||||||
|
onCardFocus = {},
|
||||||
|
focusRequester = focusRequesters[DISCOVER_ROW],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,6 @@ fun SeriesOverview(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
it.id,
|
it.id,
|
||||||
resumePosition.inWholeMilliseconds,
|
resumePosition.inWholeMilliseconds,
|
||||||
it,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -327,7 +326,6 @@ fun SeriesOverview(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
it.id,
|
it.id,
|
||||||
resume.inWholeMilliseconds,
|
resume.inWholeMilliseconds,
|
||||||
it,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
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.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
|
|
@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
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.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
|
|
@ -84,6 +86,7 @@ class SeriesViewModel
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
@Assisted val seriesId: UUID,
|
@Assisted val seriesId: UUID,
|
||||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
@Assisted val seriesPageType: SeriesPageType,
|
@Assisted val seriesPageType: SeriesPageType,
|
||||||
|
|
@ -107,6 +110,7 @@ class SeriesViewModel
|
||||||
val similar = MutableLiveData<List<BaseItem>>()
|
val similar = MutableLiveData<List<BaseItem>>()
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||||
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||||
|
|
||||||
|
|
@ -210,6 +214,10 @@ class SeriesViewModel
|
||||||
this@SeriesViewModel.similar.setValueOnMain(similar)
|
this@SeriesViewModel.similar.setValueOnMain(similar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val results = seerrService.similar(item).orEmpty()
|
||||||
|
discovered.update { results }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
|
import com.github.damontecres.wholphin.ui.logTab
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val rememberedTabIndex =
|
||||||
|
remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) }
|
||||||
|
|
||||||
|
val tabs =
|
||||||
|
listOf(
|
||||||
|
stringResource(R.string.discover),
|
||||||
|
stringResource(R.string.request),
|
||||||
|
)
|
||||||
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
||||||
|
val firstTabFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||||
|
|
||||||
|
LaunchedEffect(selectedTabIndex) {
|
||||||
|
logTab("discover", selectedTabIndex)
|
||||||
|
preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex)
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") }
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
showHeader,
|
||||||
|
enter = slideInVertically() + fadeIn(),
|
||||||
|
exit = slideOutVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
|
||||||
|
.focusRequester(firstTabFocusRequester),
|
||||||
|
tabs = tabs,
|
||||||
|
onClick = { selectedTabIndex = it },
|
||||||
|
focusRequesters = tabFocusRequesters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (selectedTabIndex) {
|
||||||
|
// Discover
|
||||||
|
0 -> {
|
||||||
|
SeerrDiscoverPage(
|
||||||
|
preferences = preferences,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
1 -> {
|
||||||
|
SeerrRequestsPage(
|
||||||
|
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
import com.google.common.cache.CacheBuilder
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SeerrDiscoverViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state = MutableStateFlow<DiscoverState>(DiscoverState())
|
||||||
|
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
fetchAndUpdateState(seerrService::discoverMovies) {
|
||||||
|
this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
|
||||||
|
}
|
||||||
|
fetchAndUpdateState(seerrService::discoverTv) {
|
||||||
|
this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
|
||||||
|
}
|
||||||
|
fetchAndUpdateState(seerrService::trending) {
|
||||||
|
this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it))
|
||||||
|
}
|
||||||
|
fetchAndUpdateState(seerrService::upcomingMovies) {
|
||||||
|
this.copy(
|
||||||
|
upcomingMovies =
|
||||||
|
DiscoverRowData(context.getString(R.string.upcoming_movies), it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fetchAndUpdateState(seerrService::upcomingTv) {
|
||||||
|
this.copy(
|
||||||
|
upcomingTv =
|
||||||
|
DiscoverRowData(context.getString(R.string.upcoming_tv), it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndUpdateState(
|
||||||
|
getData: suspend () -> List<DiscoverItem>,
|
||||||
|
copyFunc: DiscoverState.(DataLoadingState<List<DiscoverItem>>) -> DiscoverState,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
state.update {
|
||||||
|
copyFunc.invoke(it, DataLoadingState.Loading)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val results = getData.invoke()
|
||||||
|
state.update {
|
||||||
|
copyFunc.invoke(it, DataLoadingState.Success(results))
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
state.update {
|
||||||
|
copyFunc.invoke(it, DataLoadingState.Error(ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: DiscoverItem?) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (item != null) {
|
||||||
|
backdropService.submit("discover_${item.id}", item.backDropUrl)
|
||||||
|
fetchRating(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ratingCache =
|
||||||
|
CacheBuilder
|
||||||
|
.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.build<Int, DiscoverRating>()
|
||||||
|
|
||||||
|
// TODO this is not very efficient
|
||||||
|
fun fetchRating(item: DiscoverItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val cachedResult = ratingCache.getIfPresent(item.id)
|
||||||
|
if (cachedResult != null) {
|
||||||
|
return@launchIO
|
||||||
|
}
|
||||||
|
val result =
|
||||||
|
when (item.type) {
|
||||||
|
SeerrItemType.MOVIE -> {
|
||||||
|
DiscoverRating(
|
||||||
|
seerrService.api.moviesApi.movieMovieIdRatingsGet(
|
||||||
|
movieId = item.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.TV -> {
|
||||||
|
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.PERSON -> {
|
||||||
|
DiscoverRating(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.UNKNOWN -> {
|
||||||
|
DiscoverRating(null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ratingCache.put(item.id, result)
|
||||||
|
rating.update {
|
||||||
|
ratingCache.asMap().toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DiscoverRowData(
|
||||||
|
val title: String,
|
||||||
|
val items: DataLoadingState<List<DiscoverItem>>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DiscoverState(
|
||||||
|
val movies: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
|
val tv: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
|
val trending: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
|
val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
|
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeerrDiscoverPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SeerrDiscoverViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val rows =
|
||||||
|
listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv)
|
||||||
|
val ratingMap by viewModel.rating.collectAsState()
|
||||||
|
|
||||||
|
val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } }
|
||||||
|
var position by rememberPosition(0, -1)
|
||||||
|
val focusedItem =
|
||||||
|
remember(position) {
|
||||||
|
position.let {
|
||||||
|
(rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(focusedItem) {
|
||||||
|
viewModel.updateBackdrop(focusedItem)
|
||||||
|
}
|
||||||
|
var firstFocused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.trending) {
|
||||||
|
if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) {
|
||||||
|
firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
HomePageHeader(
|
||||||
|
title = focusedItem?.title,
|
||||||
|
subtitle = focusedItem?.subtitle,
|
||||||
|
overview = focusedItem?.overview,
|
||||||
|
overviewTwoLines = true,
|
||||||
|
quickDetails = {
|
||||||
|
DiscoverQuickDetails(
|
||||||
|
item = focusedItem,
|
||||||
|
rating = focusedItem?.id?.let { ratingMap[it] },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||||
|
.fillMaxHeight(.25f),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRestorer()
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(rows) { rowIndex, row ->
|
||||||
|
DiscoverRow(
|
||||||
|
row = row,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
position = RowColumn(rowIndex, index)
|
||||||
|
viewModel.navigationManager.navigateTo(item.destination)
|
||||||
|
},
|
||||||
|
onLongClickItem = { index, item -> },
|
||||||
|
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
|
||||||
|
focusRequester = focusRequesters[rowIndex],
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverRow(
|
||||||
|
row: DiscoverRowData,
|
||||||
|
onClickItem: (Int, DiscoverItem) -> Unit,
|
||||||
|
onLongClickItem: (Int, DiscoverItem) -> Unit,
|
||||||
|
onCardFocus: (Int) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (val state = row.items) {
|
||||||
|
is DataLoadingState.Error -> {
|
||||||
|
ErrorMessage(state.message, state.exception, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
DataLoadingState.Loading,
|
||||||
|
DataLoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = row.title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.loading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is DataLoadingState.Success<List<DiscoverItem>> -> {
|
||||||
|
ItemRow(
|
||||||
|
title = row.title,
|
||||||
|
items = state.data,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = onLongClickItem,
|
||||||
|
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = false,
|
||||||
|
modifier =
|
||||||
|
mod.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
onCardFocus.invoke(index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SeerrRequestsViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
private val backdropService: BackdropService,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state = MutableStateFlow(SeerrRequestsState.EMPTY)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
seerrServerRepository.current
|
||||||
|
.onEach { user ->
|
||||||
|
state.update { it.copy(requests = DataLoadingState.Loading) }
|
||||||
|
if (user != null) {
|
||||||
|
val semaphore = Semaphore(3)
|
||||||
|
val mediaRequests =
|
||||||
|
seerrService.api.requestApi
|
||||||
|
.requestGet()
|
||||||
|
.results
|
||||||
|
.orEmpty()
|
||||||
|
val requests =
|
||||||
|
mediaRequests.mapNotNull { request ->
|
||||||
|
if (request.media?.tmdbId != null) {
|
||||||
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val type = SeerrItemType.fromString(request.type)
|
||||||
|
when (type) {
|
||||||
|
SeerrItemType.MOVIE -> {
|
||||||
|
seerrService.api.moviesApi
|
||||||
|
.movieMovieIdGet(
|
||||||
|
movieId = request.media.tmdbId,
|
||||||
|
).let { DiscoverItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.TV -> {
|
||||||
|
seerrService.api.tvApi
|
||||||
|
.tvTvIdGet(tvId = request.media.tmdbId)
|
||||||
|
.let { DiscoverItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.PERSON -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.UNKNOWN -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}?.let { RequestGridItem(request, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.v("No TMDB ID for request %s", request.id)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val results = requests.awaitAll().filterNotNull()
|
||||||
|
|
||||||
|
state.update { it.copy(requests = DataLoadingState.Success(results)) }
|
||||||
|
}
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: DiscoverItem?) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (item != null) {
|
||||||
|
backdropService.submit("discover_${item.id}", item.backDropUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SeerrRequestsState(
|
||||||
|
val requests: DataLoadingState<List<RequestGridItem>>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = SeerrRequestsState(DataLoadingState.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RequestGridItem(
|
||||||
|
val request: MediaRequest,
|
||||||
|
val item: DiscoverItem,
|
||||||
|
) : CardGridItem {
|
||||||
|
override val gridId: String = request.id.toString()
|
||||||
|
override val playable: Boolean = false
|
||||||
|
override val sortName: String = request.updatedAt ?: "0000"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeerrRequestsPage(
|
||||||
|
focusRequesterOnEmpty: FocusRequester?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SeerrRequestsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY)
|
||||||
|
|
||||||
|
when (val state = state.requests) {
|
||||||
|
is DataLoadingState.Error -> {
|
||||||
|
ErrorMessage(state.message, state.exception, modifier.focusable())
|
||||||
|
}
|
||||||
|
|
||||||
|
DataLoadingState.Loading,
|
||||||
|
DataLoadingState.Pending,
|
||||||
|
-> {
|
||||||
|
LoadingPage(modifier.focusable())
|
||||||
|
}
|
||||||
|
|
||||||
|
is DataLoadingState.Success<List<RequestGridItem>> -> {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (state.data.isNotEmpty()) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
} else {
|
||||||
|
focusRequesterOnEmpty?.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
// Text(
|
||||||
|
// text = stringResource(R.string.request),
|
||||||
|
// style = MaterialTheme.typography.displaySmall,
|
||||||
|
// color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
// textAlign = TextAlign.Center,
|
||||||
|
// modifier = Modifier.fillMaxWidth(),
|
||||||
|
// )
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_results),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardGrid(
|
||||||
|
pager = state.data,
|
||||||
|
onClickItem = { index: Int, item: RequestGridItem ->
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item))
|
||||||
|
},
|
||||||
|
onLongClickItem = { index: Int, item: RequestGridItem ->
|
||||||
|
},
|
||||||
|
onClickPlay = { _, item ->
|
||||||
|
},
|
||||||
|
letterPosition = { c: Char -> 0 },
|
||||||
|
gridFocusRequester = focusRequester,
|
||||||
|
showJumpButtons = false,
|
||||||
|
showLetterButtons = false,
|
||||||
|
spacing = 16.dp,
|
||||||
|
cardContent = @Composable { item, onClick, onLongClick, mod ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item?.item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = true,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
columns = 6,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -444,12 +444,39 @@ fun HomePageHeader(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
item?.let {
|
item?.let {
|
||||||
|
val isEpisode = item.type == BaseItemKind.EPISODE
|
||||||
val dto = item.data
|
val dto = item.data
|
||||||
|
HomePageHeader(
|
||||||
|
title = item.title,
|
||||||
|
subtitle = if (isEpisode) dto.name else null,
|
||||||
|
overview = dto.overview,
|
||||||
|
overviewTwoLines = isEpisode,
|
||||||
|
quickDetails = {
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
|
||||||
|
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
|
||||||
|
else -> MovieQuickDetails(dto, Modifier)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomePageHeader(
|
||||||
|
title: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
overview: String?,
|
||||||
|
overviewTwoLines: Boolean,
|
||||||
|
quickDetails: (@Composable () -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
item.title?.let {
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
|
@ -466,28 +493,21 @@ fun HomePageHeader(
|
||||||
.fillMaxWidth(.6f)
|
.fillMaxWidth(.6f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
) {
|
) {
|
||||||
val isEpisode = item.type == BaseItemKind.EPISODE
|
|
||||||
val subtitle = if (isEpisode) dto.name else null
|
|
||||||
val overview = dto.overview
|
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
EpisodeName(it)
|
EpisodeName(it)
|
||||||
}
|
}
|
||||||
when (item.type) {
|
quickDetails?.invoke()
|
||||||
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
|
|
||||||
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
|
|
||||||
else -> MovieQuickDetails(dto, Modifier)
|
|
||||||
}
|
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
|
.height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp)
|
||||||
.width(400.dp)
|
.width(400.dp)
|
||||||
if (overview.isNotNullOrBlank()) {
|
if (overview.isNotNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = overview,
|
text = overview,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = if (isEpisode) 2 else 3,
|
maxLines = if (overviewTwoLines) 2 else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = overviewModifier,
|
modifier = overviewModifier,
|
||||||
)
|
)
|
||||||
|
|
@ -497,4 +517,3 @@ fun HomePageHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme
|
||||||
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.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
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.SeerrService
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
|
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
|
@ -50,7 +54,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
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.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -58,6 +65,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
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
|
||||||
|
|
@ -72,11 +80,13 @@ class SearchViewModel
|
||||||
constructor(
|
constructor(
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
|
||||||
private var currentQuery: String? = null
|
private var currentQuery: String? = null
|
||||||
|
|
||||||
|
|
@ -94,11 +104,13 @@ class SearchViewModel
|
||||||
searchInternal(query, BaseItemKind.SERIES, series)
|
searchInternal(query, BaseItemKind.SERIES, series)
|
||||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||||
|
searchSeerr(query)
|
||||||
} else {
|
} else {
|
||||||
movies.value = SearchResult.NoQuery
|
movies.value = SearchResult.NoQuery
|
||||||
series.value = SearchResult.NoQuery
|
series.value = SearchResult.NoQuery
|
||||||
episodes.value = SearchResult.NoQuery
|
episodes.value = SearchResult.NoQuery
|
||||||
collections.value = SearchResult.NoQuery
|
collections.value = SearchResult.NoQuery
|
||||||
|
seerrResults.value = SearchResult.NoQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +144,20 @@ class SearchViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun searchSeerr(query: String) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (seerrService.active.first()) {
|
||||||
|
seerrResults.setValueOnMain(SearchResult.Searching)
|
||||||
|
val results =
|
||||||
|
seerrService
|
||||||
|
.search(query)
|
||||||
|
.map { DiscoverItem(it) }
|
||||||
|
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
||||||
|
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getHints(query: String) {
|
fun getHints(query: String) {
|
||||||
// TODO
|
// TODO
|
||||||
// api.searchApi.getSearchHints()
|
// api.searchApi.getSearchHints()
|
||||||
|
|
@ -150,12 +176,17 @@ sealed interface SearchResult {
|
||||||
data class Success(
|
data class Success(
|
||||||
val items: List<BaseItem?>,
|
val items: List<BaseItem?>,
|
||||||
) : SearchResult
|
) : SearchResult
|
||||||
|
|
||||||
|
data class SuccessSeerr(
|
||||||
|
val items: List<DiscoverItem>,
|
||||||
|
) : SearchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val MOVIE_ROW = 0
|
private const val MOVIE_ROW = 0
|
||||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
||||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
private const val SERIES_ROW = COLLECTION_ROW + 1
|
||||||
private const val EPISODE_ROW = SERIES_ROW + 1
|
private const val EPISODE_ROW = SERIES_ROW + 1
|
||||||
|
private const val SEERR_ROW = EPISODE_ROW + 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchPage(
|
fun SearchPage(
|
||||||
|
|
@ -170,6 +201,7 @@ fun SearchPage(
|
||||||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
||||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||||
|
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||||
|
|
||||||
// val query = rememberTextFieldState()
|
// val query = rememberTextFieldState()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
@ -288,6 +320,30 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = context.getString(R.string.discover),
|
||||||
|
result = seerrResults,
|
||||||
|
rowIndex = SEERR_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
onClickItem = { _, _ ->
|
||||||
|
// no-op
|
||||||
|
},
|
||||||
|
onClickDiscover = { _, item ->
|
||||||
|
val dest =
|
||||||
|
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = item.jellyfinItemId,
|
||||||
|
type = item.type.baseItemKind,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.DiscoveredItem(item)
|
||||||
|
}
|
||||||
|
viewModel.navigationManager.navigateTo(dest)
|
||||||
|
},
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +356,7 @@ fun LazyListScope.searchResultRow(
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onClickPosition: (RowColumn) -> Unit,
|
onClickPosition: (RowColumn) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null,
|
||||||
cardContent: @Composable (
|
cardContent: @Composable (
|
||||||
index: Int,
|
index: Int,
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
|
|
@ -365,6 +422,36 @@ fun LazyListScope.searchResultRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SearchResult.SuccessSeerr -> {
|
||||||
|
if (r.items.isEmpty()) {
|
||||||
|
SearchResultPlaceholder(
|
||||||
|
title = title,
|
||||||
|
message = stringResource(R.string.no_results),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ItemRow(
|
||||||
|
title = title,
|
||||||
|
items = r.items,
|
||||||
|
onClickItem = { index, item ->
|
||||||
|
onClickPosition.invoke(RowColumn(rowIndex, index))
|
||||||
|
onClickDiscover?.invoke(index, item)
|
||||||
|
},
|
||||||
|
onLongClickItem = { _, _ -> },
|
||||||
|
modifier = modifier,
|
||||||
|
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||||
|
DiscoverItemCard(
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
showOverlay = true,
|
||||||
|
modifier = mod,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,16 @@ import androidx.annotation.StringRes
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
|
||||||
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.CollectionType
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -45,7 +45,6 @@ sealed class Destination(
|
||||||
data class SeriesOverview(
|
data class SeriesOverview(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val type: BaseItemKind,
|
val type: BaseItemKind,
|
||||||
@Transient val item: BaseItem? = null,
|
|
||||||
val seasonEpisode: SeasonEpisodeIds? = null,
|
val seasonEpisode: SeasonEpisodeIds? = null,
|
||||||
) : Destination() {
|
) : Destination() {
|
||||||
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
|
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
|
||||||
|
|
@ -55,11 +54,9 @@ sealed class Destination(
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val type: BaseItemKind,
|
val type: BaseItemKind,
|
||||||
@Transient val item: BaseItem? = null,
|
val collectionType: CollectionType? = null,
|
||||||
val seasonEpisode: SeasonEpisode? = null,
|
|
||||||
) : Destination() {
|
) : Destination() {
|
||||||
override fun toString(): String =
|
constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType)
|
||||||
"MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -71,13 +68,10 @@ sealed class Destination(
|
||||||
data class Playback(
|
data class Playback(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
@Transient val item: BaseItem? = null,
|
|
||||||
val itemPlayback: ItemPlayback? = null,
|
val itemPlayback: ItemPlayback? = null,
|
||||||
val forceTranscoding: Boolean = false,
|
val forceTranscoding: Boolean = false,
|
||||||
) : Destination(true) {
|
) : Destination(true) {
|
||||||
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
constructor(item: BaseItem) : this(item.id, item.resumeMs)
|
||||||
|
|
||||||
constructor(item: BaseItem) : this(item.id, item.resumeMs, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -109,6 +103,14 @@ sealed class Destination(
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Favorites : Destination(false)
|
data object Favorites : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Discover : Destination(false)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiscoveredItem(
|
||||||
|
val item: DiscoverItem,
|
||||||
|
) : Destination(false)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateApp : Destination(true)
|
data object UpdateApp : Destination(true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
||||||
|
|
@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
|
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||||
|
|
@ -121,7 +127,6 @@ fun DestinationContent(
|
||||||
CollectionFolderBoxSet(
|
CollectionFolderBoxSet(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
item = destination.item,
|
|
||||||
recursive = false,
|
recursive = false,
|
||||||
playEnabled = true,
|
playEnabled = true,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -141,7 +146,7 @@ fun DestinationContent(
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
collectionType = destination.item?.data?.collectionType,
|
collectionType = destination.collectionType,
|
||||||
usePostersOverride = null,
|
usePostersOverride = null,
|
||||||
recursiveOverride = null,
|
recursiveOverride = null,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -153,7 +158,7 @@ fun DestinationContent(
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
collectionType = destination.item?.data?.collectionType,
|
collectionType = destination.collectionType,
|
||||||
usePostersOverride = true,
|
usePostersOverride = true,
|
||||||
recursiveOverride = null,
|
recursiveOverride = null,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -165,7 +170,7 @@ fun DestinationContent(
|
||||||
CollectionFolder(
|
CollectionFolder(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
collectionType = destination.item?.data?.collectionType,
|
collectionType = destination.collectionType,
|
||||||
usePostersOverride = null,
|
usePostersOverride = null,
|
||||||
recursiveOverride = true,
|
recursiveOverride = true,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -247,6 +252,47 @@ fun DestinationContent(
|
||||||
Destination.Debug -> {
|
Destination.Debug -> {
|
||||||
DebugPage(preferences, modifier)
|
DebugPage(preferences, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Destination.Discover -> {
|
||||||
|
DiscoverPage(
|
||||||
|
preferences = preferences,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Destination.DiscoveredItem -> {
|
||||||
|
when (destination.item.type) {
|
||||||
|
SeerrItemType.MOVIE -> {
|
||||||
|
DiscoverMovieDetails(
|
||||||
|
preferences = preferences,
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.TV -> {
|
||||||
|
DiscoverSeriesDetails(
|
||||||
|
preferences = preferences,
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.PERSON -> {
|
||||||
|
DiscoverPersonPage(
|
||||||
|
person = destination.item,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrItemType.UNKNOWN -> {
|
||||||
|
Text(
|
||||||
|
text = "Unknown discover type",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +338,6 @@ fun CollectionFolder(
|
||||||
CollectionFolderPlaylist(
|
CollectionFolderPlaylist(
|
||||||
preferences,
|
preferences,
|
||||||
destination.itemId,
|
destination.itemId,
|
||||||
destination.item,
|
|
||||||
true,
|
true,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||||
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.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
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
|
||||||
|
|
@ -94,6 +95,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -109,17 +112,25 @@ class NavDrawerViewModel
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val setupNavigationManager: SetupNavigationManager,
|
val setupNavigationManager: SetupNavigationManager,
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var all: List<NavDrawerItem>? = null
|
// private var all: List<NavDrawerItem>? = null
|
||||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||||
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
||||||
val selectedIndex = MutableLiveData(-1)
|
val selectedIndex = MutableLiveData(-1)
|
||||||
val showMore = MutableLiveData(false)
|
val showMore = MutableLiveData(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
seerrServerRepository.active
|
||||||
|
.onEach {
|
||||||
|
init()
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val all = all ?: navDrawerItemRepository.getNavDrawerItems()
|
val all = navDrawerItemRepository.getNavDrawerItems()
|
||||||
this@NavDrawerViewModel.all = all
|
// this@NavDrawerViewModel.all = all
|
||||||
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
|
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
|
||||||
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
|
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
|
||||||
|
|
||||||
|
|
@ -128,11 +139,19 @@ class NavDrawerViewModel
|
||||||
this@NavDrawerViewModel.libraries.value = libraries
|
this@NavDrawerViewModel.libraries.value = libraries
|
||||||
}
|
}
|
||||||
val asDestinations =
|
val asDestinations =
|
||||||
(libraries + listOf(NavDrawerItem.More) + moreLibraries).map {
|
(
|
||||||
|
libraries +
|
||||||
|
listOf(
|
||||||
|
NavDrawerItem.More,
|
||||||
|
NavDrawerItem.Discover,
|
||||||
|
) + moreLibraries
|
||||||
|
).map {
|
||||||
if (it is ServerNavDrawerItem) {
|
if (it is ServerNavDrawerItem) {
|
||||||
it.destination
|
it.destination
|
||||||
} else if (it is NavDrawerItem.Favorites) {
|
} else if (it is NavDrawerItem.Favorites) {
|
||||||
Destination.Favorites
|
Destination.Favorites
|
||||||
|
} else if (it is NavDrawerItem.Discover) {
|
||||||
|
Destination.Discover
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +174,7 @@ class NavDrawerViewModel
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Timber.v("Found $index => $key")
|
Timber.v("Found $index => $key")
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
selectedIndex.setValueOnMain(index)
|
selectedIndex.setValueOnMain(index)
|
||||||
break
|
break
|
||||||
|
|
@ -192,6 +211,13 @@ sealed interface NavDrawerItem {
|
||||||
|
|
||||||
override fun name(context: Context): String = context.getString(R.string.more)
|
override fun name(context: Context): String = context.getString(R.string.more)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Discover : NavDrawerItem {
|
||||||
|
override val id: String
|
||||||
|
get() = "a_discover"
|
||||||
|
|
||||||
|
override fun name(context: Context): String = context.getString(R.string.discover)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ServerNavDrawerItem(
|
data class ServerNavDrawerItem(
|
||||||
|
|
@ -267,6 +293,13 @@ fun NavDrawer(
|
||||||
setShowMore(!showMore)
|
setShowMore(!showMore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavDrawerItem.Discover -> {
|
||||||
|
viewModel.setIndex(index)
|
||||||
|
viewModel.navigationManager.navigateToFromDrawer(
|
||||||
|
Destination.Discover,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is ServerNavDrawerItem -> {
|
is ServerNavDrawerItem -> {
|
||||||
viewModel.setIndex(index)
|
viewModel.setIndex(index)
|
||||||
viewModel.navigationManager.navigateToFromDrawer(item.destination)
|
viewModel.navigationManager.navigateToFromDrawer(item.destination)
|
||||||
|
|
@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem(
|
||||||
R.string.fa_ellipsis
|
R.string.fa_ellipsis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavDrawerItem.Discover -> {
|
||||||
|
R.string.fa_magnifying_glass_plus
|
||||||
|
}
|
||||||
|
|
||||||
is ServerNavDrawerItem -> {
|
is ServerNavDrawerItem -> {
|
||||||
when (library.type) {
|
when (library.type) {
|
||||||
CollectionType.MOVIES -> R.string.fa_film
|
CollectionType.MOVIES -> R.string.fa_film
|
||||||
|
|
|
||||||
|
|
@ -241,13 +241,7 @@ class PlaybackViewModel
|
||||||
"Error preparing for playback for $itemId",
|
"Error preparing for playback for $itemId",
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val destItem = (destination as? Destination.Playback)?.item?.data
|
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
val queriedItem =
|
|
||||||
if (destItem?.mediaSources != null) {
|
|
||||||
destItem
|
|
||||||
} else {
|
|
||||||
api.userLibraryApi.getItem(itemId).content
|
|
||||||
}
|
|
||||||
val base =
|
val base =
|
||||||
if (queriedItem.type.playable) {
|
if (queriedItem.type.playable) {
|
||||||
queriedItem
|
queriedItem
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
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.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
|
@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.uiPreferences
|
import com.github.damontecres.wholphin.preferences.uiPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
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.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -58,9 +61,12 @@ 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.preferences.subtitle.SubtitleStylePage
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||||
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.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
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -71,6 +77,7 @@ fun PreferencesContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PreferencesViewModel = hiltViewModel(),
|
viewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
updateVM: UpdateViewModel = hiltViewModel(),
|
updateVM: UpdateViewModel = hiltViewModel(),
|
||||||
|
seerrVm: SwitchSeerrViewModel = hiltViewModel(),
|
||||||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -84,6 +91,8 @@ fun PreferencesContent(
|
||||||
|
|
||||||
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
||||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||||
|
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
|
||||||
|
var showSeerrServerDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.preferenceDataStore.data.collect {
|
viewModel.preferenceDataStore.data.collect {
|
||||||
|
|
@ -381,6 +390,22 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppPreference.SeerrIntegration -> {
|
||||||
|
ClickPreference(
|
||||||
|
title = stringResource(pref.title),
|
||||||
|
onClick = { showSeerrServerDialog = true },
|
||||||
|
modifier = Modifier,
|
||||||
|
summary =
|
||||||
|
if (seerrIntegrationEnabled) {
|
||||||
|
stringResource(R.string.enabled)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onLongClick = {},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
ComposablePreference(
|
ComposablePreference(
|
||||||
|
|
@ -440,6 +465,39 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (showSeerrServerDialog) {
|
||||||
|
if (seerrIntegrationEnabled) {
|
||||||
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.remove_seerr_server),
|
||||||
|
body = "",
|
||||||
|
onCancel = { showSeerrServerDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
seerrVm.removeServer()
|
||||||
|
showSeerrServerDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val currentUser by seerrVm.currentUser.observeAsState()
|
||||||
|
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
||||||
|
LaunchedEffect(status) {
|
||||||
|
if (status == LoadingState.Success) {
|
||||||
|
showSeerrServerDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddSeerServerDialog(
|
||||||
|
currentUsername = currentUser?.name,
|
||||||
|
status = status,
|
||||||
|
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod ->
|
||||||
|
if (method == SeerrAuthMethod.API_KEY) {
|
||||||
|
seerrVm.submitServer(url, passwordOrApiKey)
|
||||||
|
} else {
|
||||||
|
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = { showSeerrServerDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.asFlow
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
|
|
@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
|
|
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
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.flow.combine
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
|
@ -43,6 +46,7 @@ class PreferencesViewModel
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
private val serverPreferencesDao: ServerPreferencesDao,
|
private val serverPreferencesDao: ServerPreferencesDao,
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
private val deviceInfo: DeviceInfo,
|
private val deviceInfo: DeviceInfo,
|
||||||
private val clientInfo: ClientInfo,
|
private val clientInfo: ClientInfo,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
|
|
@ -52,6 +56,11 @@ class PreferencesViewModel
|
||||||
|
|
||||||
val currentUser get() = serverRepository.currentUser
|
val currentUser get() = serverRepository.currentUser
|
||||||
|
|
||||||
|
val seerrEnabled =
|
||||||
|
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
|
||||||
|
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.components.EditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddSeerrServerApiKey(
|
||||||
|
onSubmit: (url: String, apiKey: String) -> Unit,
|
||||||
|
status: LoadingState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.padding(16.dp)
|
||||||
|
.wrapContentSize(),
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Text(
|
||||||
|
text = "Enter URL & API Key",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "URL",
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = url,
|
||||||
|
onValueChange = {
|
||||||
|
error = null
|
||||||
|
url = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onNext = {
|
||||||
|
passwordFocusRequester.tryRequestFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "API Key",
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = apiKey,
|
||||||
|
onValueChange = {
|
||||||
|
error = null
|
||||||
|
apiKey = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onGo = { onSubmit.invoke(url, apiKey) },
|
||||||
|
),
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.focusRequester(passwordFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
stringRes = R.string.submit,
|
||||||
|
onClick = { onSubmit.invoke(url, apiKey) },
|
||||||
|
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(),
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddSeerrServerUsername(
|
||||||
|
onSubmit: (url: String, username: String, password: String) -> Unit,
|
||||||
|
username: String,
|
||||||
|
status: LoadingState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.padding(16.dp)
|
||||||
|
.wrapContentSize(),
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var username by remember { mutableStateOf(username) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val usernameFocusRequester = remember { FocusRequester() }
|
||||||
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.username_or_password),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
val labelWidth = 90.dp
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.url),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(labelWidth)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = url,
|
||||||
|
onValueChange = {
|
||||||
|
error = null
|
||||||
|
url = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onNext = {
|
||||||
|
usernameFocusRequester.tryRequestFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.username),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(labelWidth)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = username,
|
||||||
|
onValueChange = {
|
||||||
|
error = null
|
||||||
|
username = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onNext = {
|
||||||
|
passwordFocusRequester.tryRequestFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.password),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(labelWidth)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
error = null
|
||||||
|
password = it
|
||||||
|
},
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onGo = { onSubmit.invoke(url, username, password) },
|
||||||
|
),
|
||||||
|
isInputValid = { true },
|
||||||
|
modifier = Modifier.focusRequester(passwordFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
stringRes = R.string.submit,
|
||||||
|
onClick = { onSubmit.invoke(url, username, password) },
|
||||||
|
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(),
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
private fun AddSeerrServerUsernamePreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
AddSeerrServerUsername(
|
||||||
|
onSubmit = { string: String, string1: String, string2: String -> },
|
||||||
|
username = "test",
|
||||||
|
status = LoadingState.Pending,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddSeerServerDialog(
|
||||||
|
currentUsername: String?,
|
||||||
|
status: LoadingState,
|
||||||
|
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
|
||||||
|
LaunchedEffect(status) {
|
||||||
|
if (status is LoadingState.Success) {
|
||||||
|
onDismissRequest.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val auth = authMethod) {
|
||||||
|
SeerrAuthMethod.LOCAL,
|
||||||
|
SeerrAuthMethod.JELLYFIN,
|
||||||
|
-> {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = { authMethod = null },
|
||||||
|
) {
|
||||||
|
AddSeerrServerUsername(
|
||||||
|
onSubmit = { url, username, password ->
|
||||||
|
onSubmit.invoke(url, username, password, auth)
|
||||||
|
},
|
||||||
|
username = currentUsername ?: "",
|
||||||
|
status = status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAuthMethod.API_KEY -> {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = { authMethod = null },
|
||||||
|
) {
|
||||||
|
AddSeerrServerApiKey(
|
||||||
|
onSubmit = { url, apiKey ->
|
||||||
|
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY)
|
||||||
|
},
|
||||||
|
status = status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
ChooseSeerrLoginType(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onChoose = { authMethod = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChooseSeerrLoginType(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onChoose: (SeerrAuthMethod) -> Unit,
|
||||||
|
) {
|
||||||
|
val params =
|
||||||
|
remember {
|
||||||
|
DialogParams(
|
||||||
|
fromLongClick = false,
|
||||||
|
title = "Login to Seerr server",
|
||||||
|
items =
|
||||||
|
listOf(
|
||||||
|
DialogItem(
|
||||||
|
text = "API Key",
|
||||||
|
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
text = "Jellyfin user",
|
||||||
|
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
|
||||||
|
),
|
||||||
|
DialogItem(
|
||||||
|
text = "Local user",
|
||||||
|
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DialogPopup(
|
||||||
|
params = params,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SwitchSeerrViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val currentUser = serverRepository.currentUser
|
||||||
|
|
||||||
|
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||||
|
|
||||||
|
private fun cleanUrl(url: String) =
|
||||||
|
if (!url.endsWith("/api/v1")) {
|
||||||
|
url
|
||||||
|
.toHttpUrlOrNull()
|
||||||
|
?.newBuilder()
|
||||||
|
?.apply {
|
||||||
|
addPathSegment("api")
|
||||||
|
addPathSegment("v1")
|
||||||
|
}?.build()
|
||||||
|
.toString()
|
||||||
|
} else {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitServer(
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val url = cleanUrl(url)
|
||||||
|
val result =
|
||||||
|
seerrServerRepository.testConnection(
|
||||||
|
authMethod = SeerrAuthMethod.API_KEY,
|
||||||
|
url = url,
|
||||||
|
username = null,
|
||||||
|
passwordOrApiKey = apiKey,
|
||||||
|
)
|
||||||
|
if (result is LoadingState.Success) {
|
||||||
|
seerrServerRepository.addAndChangeServer(url, apiKey)
|
||||||
|
}
|
||||||
|
serverConnectionStatus.update { result }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitServer(
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
authMethod: SeerrAuthMethod,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val url = cleanUrl(url)
|
||||||
|
val result =
|
||||||
|
seerrServerRepository.testConnection(
|
||||||
|
authMethod = authMethod,
|
||||||
|
url = url,
|
||||||
|
username = username,
|
||||||
|
passwordOrApiKey = password,
|
||||||
|
)
|
||||||
|
if (result is LoadingState.Success) {
|
||||||
|
seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
|
||||||
|
}
|
||||||
|
serverConnectionStatus.update { result }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeServer() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
seerrServerRepository.removeServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic state for loading something from the API
|
* Generic state for loading something from the API
|
||||||
|
*
|
||||||
|
* @see DataLoadingState
|
||||||
*/
|
*/
|
||||||
sealed interface LoadingState {
|
sealed interface LoadingState {
|
||||||
data object Pending : LoadingState
|
data object Pending : LoadingState
|
||||||
|
|
@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState {
|
||||||
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic state for loading something from the API
|
||||||
|
*
|
||||||
|
* @see LoadingState
|
||||||
|
*/
|
||||||
|
sealed interface DataLoadingState<out T> {
|
||||||
|
data object Pending : DataLoadingState<Nothing>
|
||||||
|
|
||||||
|
data object Loading : DataLoadingState<Nothing>
|
||||||
|
|
||||||
|
data class Success<T>(
|
||||||
|
val data: T,
|
||||||
|
) : DataLoadingState<T>
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String? = null,
|
||||||
|
val exception: Throwable? = null,
|
||||||
|
) : DataLoadingState<Nothing> {
|
||||||
|
constructor(exception: Throwable) : this(null, exception)
|
||||||
|
|
||||||
|
val localizedMessage: String =
|
||||||
|
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
object LocalDateSerializer : KSerializer<LocalDate> {
|
||||||
|
override val descriptor: SerialDescriptor
|
||||||
|
get() = SerialDescriptor("LocalDate", String.serializer().descriptor)
|
||||||
|
|
||||||
|
override fun serialize(
|
||||||
|
encoder: Encoder,
|
||||||
|
value: LocalDate,
|
||||||
|
) {
|
||||||
|
encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE.format(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): LocalDate =
|
||||||
|
decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
|
||||||
|
}
|
||||||
|
|
@ -45,4 +45,8 @@
|
||||||
<string name="fa_ellipsis_vertical" translatable="false"></string>
|
<string name="fa_ellipsis_vertical" translatable="false"></string>
|
||||||
<string name="fa_filter" translatable="false"></string>
|
<string name="fa_filter" translatable="false"></string>
|
||||||
<string name="fa_sliders" translatable="false"></string>
|
<string name="fa_sliders" translatable="false"></string>
|
||||||
|
<string name="fa_download" translatable="false"></string>
|
||||||
|
<string name="fa_clock" translatable="false"></string>
|
||||||
|
<string name="fa_bell" translatable="false"></string>
|
||||||
|
<string name="fa_check" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,22 @@
|
||||||
<item quantity="other">%d seconds</item>
|
<item quantity="other">%d seconds</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="movies">
|
||||||
|
<item quantity="zero">Movies</item>
|
||||||
|
<item quantity="one">Movie</item>
|
||||||
|
<item quantity="other">Movies</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="tv_shows">
|
||||||
|
<item quantity="zero">TV Shows</item>
|
||||||
|
<item quantity="one">TV Show</item>
|
||||||
|
<item quantity="other">TV Shows</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="people">
|
||||||
|
<item quantity="zero">People</item>
|
||||||
|
<item quantity="one">Person</item>
|
||||||
|
<item quantity="other">People</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Preferences/Setting specific strings -->
|
<!-- Preferences/Setting specific strings -->
|
||||||
<string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string>
|
<string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string>
|
||||||
<string name="pref_key_update_last_check" translatable="false">preference.update.lastTimestamp</string>
|
<string name="pref_key_update_last_check" translatable="false">preference.update.lastTimestamp</string>
|
||||||
|
|
@ -413,6 +429,18 @@
|
||||||
<string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string>
|
<string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string>
|
||||||
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
|
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
|
||||||
|
|
||||||
|
<string name="discover">Discover</string>
|
||||||
|
<string name="request">Request</string>
|
||||||
|
<string name="pending">Pending</string>
|
||||||
|
<string name="seerr_integration">Seerr integration</string>
|
||||||
|
<string name="remove_seerr_server">Remove Seerr Server</string>
|
||||||
|
<string name="password">Password</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="url">URL</string>
|
||||||
|
<string name="trending">Trending</string>
|
||||||
|
<string name="upcoming_movies">Upcoming Movies</string>
|
||||||
|
<string name="upcoming_tv">Upcoming TV Shows</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
<item>Lowest</item>
|
<item>Lowest</item>
|
||||||
|
|
|
||||||
7772
app/src/main/seerr/seerr-api.yml
Normal file
7772
app/src/main/seerr/seerr-api.yml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,191 @@
|
||||||
|
package {{packageName}}.infrastructure
|
||||||
|
|
||||||
|
{{#moshi}}
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
{{#enumUnknownDefaultCase}}
|
||||||
|
import com.squareup.moshi.adapters.EnumJsonAdapter
|
||||||
|
{{/enumUnknownDefaultCase}}
|
||||||
|
{{^moshiCodeGen}}
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
{{/moshiCodeGen}}
|
||||||
|
{{/moshi}}
|
||||||
|
{{#gson}}
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
{{^threetenbp}}
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
{{/threetenbp}}
|
||||||
|
{{#threetenbp}}
|
||||||
|
import org.threeten.bp.LocalDate
|
||||||
|
import org.threeten.bp.LocalDateTime
|
||||||
|
import org.threeten.bp.OffsetDateTime
|
||||||
|
{{/threetenbp}}
|
||||||
|
{{#kotlinx-datetime}}
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
{{/kotlinx-datetime}}
|
||||||
|
import java.util.UUID
|
||||||
|
{{/gson}}
|
||||||
|
{{#jackson}}
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
{{/jackson}}
|
||||||
|
{{#kotlinx_serialization}}
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.BigInteger
|
||||||
|
{{^threetenbp}}
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
{{/threetenbp}}
|
||||||
|
{{#threetenbp}}
|
||||||
|
import org.threeten.bp.LocalDate
|
||||||
|
import org.threeten.bp.LocalDateTime
|
||||||
|
import org.threeten.bp.OffsetDateTime
|
||||||
|
{{/threetenbp}}
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonBuilder
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.SerializersModuleBuilder
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
{{/kotlinx_serialization}}
|
||||||
|
|
||||||
|
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object Serializer {
|
||||||
|
{{#moshi}}
|
||||||
|
@JvmStatic
|
||||||
|
{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshiBuilder: Moshi.Builder = Moshi.Builder()
|
||||||
|
.add(OffsetDateTimeAdapter())
|
||||||
|
{{#kotlinx-datetime}}
|
||||||
|
.add(InstantAdapter())
|
||||||
|
.add(LocalDateAdapter())
|
||||||
|
.add(LocalTimeAdapter())
|
||||||
|
{{/kotlinx-datetime}}
|
||||||
|
.add(LocalDateTimeAdapter())
|
||||||
|
.add(LocalDateAdapter())
|
||||||
|
.add(UUIDAdapter())
|
||||||
|
.add(ByteArrayAdapter())
|
||||||
|
.add(URIAdapter())
|
||||||
|
{{^moshiCodeGen}}
|
||||||
|
.add(KotlinJsonAdapterFactory())
|
||||||
|
{{/moshiCodeGen}}
|
||||||
|
.add(BigDecimalAdapter())
|
||||||
|
.add(BigIntegerAdapter())
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshi: Moshi by lazy {
|
||||||
|
{{#enumUnknownDefaultCase}}
|
||||||
|
SerializerHelper.addEnumUnknownDefaultCase(moshiBuilder)
|
||||||
|
{{/enumUnknownDefaultCase}}
|
||||||
|
moshiBuilder.build()
|
||||||
|
}
|
||||||
|
{{/moshi}}
|
||||||
|
{{#gson}}
|
||||||
|
@JvmStatic
|
||||||
|
val gsonBuilder: GsonBuilder = GsonBuilder()
|
||||||
|
.registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdapter())
|
||||||
|
{{#kotlinx-datetime}}
|
||||||
|
.registerTypeAdapter(Instant::class.java, InstantAdapter())
|
||||||
|
.registerTypeAdapter(LocalDate::class.java, LocalDateAdapter())
|
||||||
|
.registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter())
|
||||||
|
{{/kotlinx-datetime}}
|
||||||
|
.registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter())
|
||||||
|
.registerTypeAdapter(LocalDate::class.java, LocalDateAdapter())
|
||||||
|
.registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter())
|
||||||
|
{{#generateOneOfAnyOfWrappers}}
|
||||||
|
{{#models}}
|
||||||
|
{{#model}}
|
||||||
|
{{^isEnum}}
|
||||||
|
{{^hasChildren}}
|
||||||
|
.registerTypeAdapterFactory({{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory())
|
||||||
|
{{/hasChildren}}
|
||||||
|
{{/isEnum}}
|
||||||
|
{{/model}}
|
||||||
|
{{/models}}
|
||||||
|
{{/generateOneOfAnyOfWrappers}}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val gson: Gson by lazy {
|
||||||
|
gsonBuilder.create()
|
||||||
|
}
|
||||||
|
{{/gson}}
|
||||||
|
{{#jackson}}
|
||||||
|
@JvmStatic
|
||||||
|
val jacksonObjectMapper: ObjectMapper = jacksonObjectMapper()
|
||||||
|
.findAndRegisterModules()
|
||||||
|
.setSerializationInclusion(JsonInclude.Include.NON_ABSENT)
|
||||||
|
{{#enumUnknownDefaultCase}}
|
||||||
|
.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true)
|
||||||
|
{{/enumUnknownDefaultCase}}
|
||||||
|
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, {{failOnUnknownProperties}})
|
||||||
|
{{/jackson}}
|
||||||
|
{{#kotlinx_serialization}}
|
||||||
|
private var isAdaptersInitialized = false
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val kotlinxSerializationAdapters: SerializersModule by lazy {
|
||||||
|
isAdaptersInitialized = true
|
||||||
|
SerializersModule {
|
||||||
|
contextual(BigDecimal::class, BigDecimalAdapter)
|
||||||
|
contextual(BigInteger::class, BigIntegerAdapter)
|
||||||
|
{{^kotlinx-datetime}}
|
||||||
|
contextual(LocalDate::class, LocalDateAdapter)
|
||||||
|
contextual(LocalDateTime::class, LocalDateTimeAdapter)
|
||||||
|
contextual(OffsetDateTime::class, OffsetDateTimeAdapter)
|
||||||
|
{{/kotlinx-datetime}}
|
||||||
|
contextual(UUID::class, UUIDAdapter)
|
||||||
|
contextual(AtomicInteger::class, AtomicIntegerAdapter)
|
||||||
|
contextual(AtomicLong::class, AtomicLongAdapter)
|
||||||
|
contextual(AtomicBoolean::class, AtomicBooleanAdapter)
|
||||||
|
contextual(URI::class, URIAdapter)
|
||||||
|
contextual(URL::class, URLAdapter)
|
||||||
|
contextual(StringBuilder::class, StringBuilderAdapter)
|
||||||
|
|
||||||
|
apply(kotlinxSerializationAdaptersConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var kotlinxSerializationAdaptersConfiguration: SerializersModuleBuilder.() -> Unit = {}
|
||||||
|
set(value) {
|
||||||
|
check(!isAdaptersInitialized) {
|
||||||
|
"Cannot configure kotlinxSerializationAdaptersConfiguration after kotlinxSerializationAdapters has been initialized."
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isJsonInitialized = false
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val kotlinxSerializationJson: Json by lazy {
|
||||||
|
isJsonInitialized = true
|
||||||
|
Json {
|
||||||
|
serializersModule = kotlinxSerializationAdapters
|
||||||
|
encodeDefaults = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
explicitNulls = false
|
||||||
|
|
||||||
|
apply(kotlinxSerializationJsonConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var kotlinxSerializationJsonConfiguration: JsonBuilder.() -> Unit = {}
|
||||||
|
set(value) {
|
||||||
|
check(!isJsonInitialized) {
|
||||||
|
"Cannot configure kotlinxSerializationJsonConfiguration after kotlinxSerializationJson has been initialized."
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
{{/kotlinx_serialization}}
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,14 @@ hiltCompiler = "1.3.0"
|
||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
hiltWork = "1.3.0"
|
hiltWork = "1.3.0"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
ksp = "2.3.0"
|
ksp = "2.3.0"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2025.12.01"
|
||||||
mockk = "1.14.7"
|
mockk = "1.14.7"
|
||||||
multiplatformMarkdownRenderer = "0.39.0"
|
multiplatformMarkdownRenderer = "0.39.0"
|
||||||
|
okhttpBom = "5.3.2"
|
||||||
programguide = "1.6.0"
|
programguide = "1.6.0"
|
||||||
slf4j2Timber = "1.2"
|
slf4j2Timber = "1.2"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
|
|
@ -38,6 +40,7 @@ preferenceKtx = "1.2.1"
|
||||||
tvprovider = "1.1.0"
|
tvprovider = "1.1.0"
|
||||||
workRuntimeKtx = "2.11.0"
|
workRuntimeKtx = "2.11.0"
|
||||||
paletteKtx = "1.0.0"
|
paletteKtx = "1.0.0"
|
||||||
|
openapi-generator = "7.18.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
|
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
|
||||||
|
|
@ -73,10 +76,13 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||||
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||||
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
|
okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
||||||
|
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" }
|
||||||
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }
|
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }
|
||||||
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
|
|
@ -127,3 +133,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
room = { id = "androidx.room", version.ref = "room" }
|
room = { id = "androidx.room", version.ref = "room" }
|
||||||
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }
|
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }
|
||||||
|
openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" }
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ pluginManagement {
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
// maven {
|
|
||||||
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
|
|
@ -19,9 +16,6 @@ dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
// maven {
|
|
||||||
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue