mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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.kotlin.plugin.serialization)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.openapi.generator)
|
||||
}
|
||||
|
||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
||||
|
|
@ -145,6 +146,12 @@ android {
|
|||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
|
@ -246,6 +280,8 @@ dependencies {
|
|||
implementation(libs.acra.limiter)
|
||||
compileOnly(libs.auto.service.annotations)
|
||||
ksp(libs.auto.service.ksp)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
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.SetupNavigationManager
|
||||
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.tvprovider.TvProviderSchedulerService
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
|
|
@ -105,6 +106,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var refreshRateService: RefreshRateService
|
||||
|
||||
@Inject
|
||||
lateinit var userSwitchListener: UserSwitchListener
|
||||
|
||||
@Inject
|
||||
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.NavDrawerPinnedItem
|
||||
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 kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
|
|
@ -32,8 +34,10 @@ import java.util.UUID
|
|||
LibraryDisplayInfo::class,
|
||||
PlaybackLanguageChoice::class,
|
||||
ItemTrackModification::class,
|
||||
SeerrServer::class,
|
||||
SeerrUser::class,
|
||||
],
|
||||
version = 12,
|
||||
version = 20,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -45,6 +49,7 @@ import java.util.UUID
|
|||
AutoMigration(9, 10),
|
||||
AutoMigration(10, 11),
|
||||
AutoMigration(11, 12),
|
||||
AutoMigration(12, 20),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
|
||||
|
||||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
|
||||
abstract fun seerrServerDao(): SeerrServerDao
|
||||
}
|
||||
|
||||
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.NavDrawerPinnedItem
|
||||
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.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -24,6 +26,7 @@ class NavDrawerItemRepository
|
|||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
|
||||
val user = serverRepository.currentUser.value
|
||||
|
|
@ -46,7 +49,13 @@ class NavDrawerItemRepository
|
|||
setOf()
|
||||
}
|
||||
|
||||
val builtins = listOf(NavDrawerItem.Favorites)
|
||||
val builtins =
|
||||
if (seerrServerRepository.active.first()) {
|
||||
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
||||
} else {
|
||||
listOf(NavDrawerItem.Favorites)
|
||||
}
|
||||
|
||||
val libraries =
|
||||
userViews
|
||||
.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 useSeriesForPrimary: Boolean,
|
||||
) : CardGridItem {
|
||||
override val id get() = data.id
|
||||
val id get() = data.id
|
||||
|
||||
override val gridId get() = id.toString()
|
||||
|
||||
override val playable: Boolean
|
||||
get() = type.playable
|
||||
|
|
@ -88,23 +90,21 @@ data class BaseItem(
|
|||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||
)
|
||||
} ?: Destination.MediaItem(id, type, this)
|
||||
} ?: Destination.MediaItem(this)
|
||||
}
|
||||
|
||||
BaseItemKind.SEASON -> {
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Destination.MediaItem(id, type, this)
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
}
|
||||
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,
|
||||
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,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SeerrIntegration,
|
||||
AppPreference.AdvancedSettings,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import coil3.request.SuccessResult
|
|||
import coil3.request.allowHardware
|
||||
import coil3.request.bitmapConfig
|
||||
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.BackdropStyle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -47,15 +47,24 @@ class BackdropService
|
|||
|
||||
suspend fun submit(item: BaseItem) =
|
||||
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) {
|
||||
_backdropFlow.update {
|
||||
it.copy(
|
||||
itemId = item.id,
|
||||
itemId = itemId,
|
||||
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)
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
val backdropStyle =
|
||||
preferences.data
|
||||
.firstOrNull()
|
||||
|
|
@ -83,9 +94,9 @@ class BackdropService
|
|||
ExtractedColors.DEFAULT
|
||||
}
|
||||
_backdropFlow.update {
|
||||
if (it.itemId == item.id) {
|
||||
if (it.itemId == itemId) {
|
||||
BackdropResult(
|
||||
itemId = item.id,
|
||||
itemId = itemId,
|
||||
imageUrl = imageUrl,
|
||||
primaryColor = primaryColor,
|
||||
secondaryColor = secondaryColor,
|
||||
|
|
@ -208,7 +219,7 @@ class BackdropService
|
|||
}
|
||||
|
||||
data class BackdropResult(
|
||||
val itemId: UUID?,
|
||||
val itemId: String?,
|
||||
val imageUrl: String?,
|
||||
val primaryColor: 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(
|
||||
Destination.Playback(
|
||||
itemId = trailer.baseItem.id,
|
||||
item = trailer.baseItem,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.headersContentLength
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
|
@ -211,7 +210,7 @@ class UpdateChecker
|
|||
if (it.isSuccessful && it.body != null) {
|
||||
Timber.v("Request successful for ${release.downloadUrl}")
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.contentLength(it.headersContentLength())
|
||||
callback.contentLength(it.body.contentLength())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
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.UserPreferences
|
||||
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.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -174,4 +175,10 @@ object AppModule {
|
|||
@Singleton
|
||||
@IoCoroutineScope
|
||||
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.Migrations
|
||||
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.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
|
|
@ -61,6 +62,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import androidx.annotation.StringRes
|
|||
import com.github.damontecres.wholphin.R
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.format.FormatStyle
|
||||
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 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#`
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ val LocalImageUrlService =
|
|||
/**
|
||||
* Colors not associated with the theme
|
||||
*/
|
||||
sealed class AppColors private constructor() {
|
||||
companion object {
|
||||
object AppColors {
|
||||
val TransparentBlack25 = Color(0x40000000)
|
||||
val TransparentBlack50 = Color(0x80000000)
|
||||
val TransparentBlack75 = Color(0xBF000000)
|
||||
|
|
@ -33,6 +32,12 @@ sealed class AppColors private constructor() {
|
|||
val DarkPurple = Color(0xFF261370)
|
||||
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
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.setup.rememberIdColor
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -173,11 +173,12 @@ class GenreViewModel
|
|||
}
|
||||
|
||||
data class Genre(
|
||||
override val id: UUID,
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val color: Color,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
override val sortName: String get() = name
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,10 +179,12 @@ fun ExpandablePlayButton(
|
|||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
mirrorIcon: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
onClick = { onClick.invoke(resume) },
|
||||
enabled = enabled,
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
|
|
@ -234,6 +236,7 @@ fun ExpandableFaButton(
|
|||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
|
|
@ -242,7 +245,6 @@ fun ExpandableFaButton(
|
|||
),
|
||||
contentPadding = DefaultButtonPadding,
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
private const val DEBUG = false
|
||||
|
||||
interface CardGridItem {
|
||||
val id: UUID
|
||||
val gridId: String
|
||||
val playable: Boolean
|
||||
val sortName: String
|
||||
}
|
||||
|
|
@ -244,7 +243,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
} else if (isPlayKeyUp(it)) {
|
||||
val item = pager.getOrNull(focusedIndex)
|
||||
if (item?.playable == true) {
|
||||
Timber.v("Clicked play on ${item.id}")
|
||||
Timber.v("Clicked play on ${item.gridId}")
|
||||
onClickPlay.invoke(focusedIndex, item)
|
||||
}
|
||||
return@onKeyEvent true
|
||||
|
|
@ -375,7 +374,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
pager
|
||||
.getOrNull(focusedIndex)
|
||||
?.sortName
|
||||
?.first()
|
||||
?.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.let {
|
||||
if (it >= '0' && it <= '9') {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
|
|
@ -25,7 +24,6 @@ import java.util.UUID
|
|||
fun CollectionFolderBoxSet(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
item: BaseItem?,
|
||||
recursive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ fun CollectionFolderMovie(
|
|||
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
|
||||
|
||||
val firstTabFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
|
||||
|
||||
LaunchedEffect(selectedTabIndex) {
|
||||
logTab("movie", selectedTabIndex)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
|
|
@ -22,7 +21,6 @@ import java.util.UUID
|
|||
fun CollectionFolderPlaylist(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
item: BaseItem?,
|
||||
recursive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
filter: CollectionFolderFilter = CollectionFolderFilter(),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ fun buildMoreDialogItems(
|
|||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
item,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -166,6 +165,7 @@ fun buildMoreDialogItems(
|
|||
Destination.MediaItem(
|
||||
seriesId,
|
||||
BaseItemKind.SERIES,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -200,7 +200,6 @@ fun buildMoreDialogItems(
|
|||
Destination.Playback(
|
||||
item.id,
|
||||
item.resumeMs ?: 0L,
|
||||
item,
|
||||
forceTranscoding = true,
|
||||
),
|
||||
)
|
||||
|
|
@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome(
|
|||
Destination.MediaItem(
|
||||
it,
|
||||
BaseItemKind.SERIES,
|
||||
null,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
|
|||
context.getString(R.string.go_to),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
|
||||
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
|
||||
},
|
||||
)
|
||||
add(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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
|
||||
|
|
@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
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.services.FavoriteWatchManager
|
||||
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.LocalImageUrlService
|
||||
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.ItemDetailsDialogInfo
|
||||
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.ifElse
|
||||
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.tryRequestFocus
|
||||
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.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||
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.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
|
@ -90,10 +98,12 @@ class PersonViewModel
|
|||
api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val seerrService: SeerrService,
|
||||
) : LoadingItemViewModel(api) {
|
||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
viewModelScope.launchIO(
|
||||
|
|
@ -119,6 +129,10 @@ class PersonViewModel
|
|||
} else {
|
||||
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 series by viewModel.series.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)
|
||||
when (val state = loading) {
|
||||
|
|
@ -219,6 +234,10 @@ fun PersonPage(
|
|||
favoriteOnClick = {
|
||||
viewModel.setFavorite(!person.favorite)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
AnimatedVisibility(showOverviewDialog) {
|
||||
|
|
@ -243,6 +262,7 @@ private const val HEADER_ROW = 0
|
|||
private const val MOVIE_ROW = 1
|
||||
private const val SERIES_ROW = 2
|
||||
private const val EPISODE_ROW = 3
|
||||
private const val DISCOVER_ROW = EPISODE_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun PersonPageContent(
|
||||
|
|
@ -257,9 +277,11 @@ fun PersonPageContent(
|
|||
movies: RowLoadingState,
|
||||
series: RowLoadingState,
|
||||
episodes: RowLoadingState,
|
||||
discovered: List<DiscoverItem>,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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(
|
||||
ep.id,
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.model.BaseItem
|
||||
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.Trailer
|
||||
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.buildMoreDialogItemsForHome
|
||||
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.rememberInt
|
||||
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
|
||||
|
|
@ -99,6 +104,7 @@ fun MovieDetails(
|
|||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -168,7 +174,6 @@ fun MovieDetails(
|
|||
Destination.Playback(
|
||||
movie.id,
|
||||
it.inWholeMilliseconds,
|
||||
movie,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -300,6 +305,10 @@ fun MovieDetails(
|
|||
onClickExtra = { index, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
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 EXTRAS_ROW = CHAPTER_ROW + 1
|
||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun MovieDetailsContent(
|
||||
|
|
@ -371,6 +381,7 @@ fun MovieDetailsContent(
|
|||
trailers: List<Trailer>,
|
||||
extras: List<ExtrasItem>,
|
||||
similar: List<BaseItem>,
|
||||
discovered: List<DiscoverItem>,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
|
|
@ -382,12 +393,13 @@ fun MovieDetailsContent(
|
|||
onLongClickPerson: (Int, Person) -> Unit,
|
||||
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
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 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.model.BaseItem
|
||||
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.Person
|
||||
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.NavigationManager
|
||||
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.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
|
|
@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -54,6 +58,7 @@ class MovieViewModel
|
|||
@AssistedInject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val seerrService: SeerrService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
|
|
@ -81,6 +86,7 @@ class MovieViewModel
|
|||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||
val similar = MutableLiveData<List<BaseItem>>()
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
init {
|
||||
init()
|
||||
|
|
@ -140,6 +146,10 @@ class MovieViewModel
|
|||
val extras = extrasService.getExtras(item.id)
|
||||
this@MovieViewModel.extras.setValueOnMain(extras)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(item).orEmpty()
|
||||
discovered.update { results }
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
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.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.data.ExtrasItem
|
||||
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.Trailer
|
||||
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.buildMoreDialogItemsForHome
|
||||
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.nav.Destination
|
||||
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.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -104,6 +109,7 @@ fun SeriesDetails(
|
|||
val extras by viewModel.extras.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||
|
|
@ -208,6 +214,10 @@ fun SeriesDetails(
|
|||
onClickExtra = { _, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
moreActions =
|
||||
MoreDialogActions(
|
||||
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 EXTRAS_ROW = TRAILER_ROW + 1
|
||||
private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||
private const val DISCOVER_ROW = SIMILAR_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailsContent(
|
||||
|
|
@ -291,6 +302,7 @@ fun SeriesDetailsContent(
|
|||
trailers: List<Trailer>,
|
||||
extras: List<ExtrasItem>,
|
||||
people: List<Person>,
|
||||
discovered: List<DiscoverItem>,
|
||||
played: Boolean,
|
||||
favorite: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
|
|
@ -303,6 +315,7 @@ fun SeriesDetailsContent(
|
|||
trailerOnClick: (Trailer) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
moreActions: MoreDialogActions,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -310,7 +323,7 @@ fun SeriesDetailsContent(
|
|||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
var position by rememberInt()
|
||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||
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(
|
||||
it.id,
|
||||
resumePosition.inWholeMilliseconds,
|
||||
it,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -327,7 +326,6 @@ fun SeriesOverview(
|
|||
Destination.Playback(
|
||||
it.id,
|
||||
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.ServerRepository
|
||||
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.Person
|
||||
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.NavigationManager
|
||||
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.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
|
|
@ -84,6 +86,7 @@ class SeriesViewModel
|
|||
val streamChoiceService: StreamChoiceService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrService: SeerrService,
|
||||
@Assisted val seriesId: UUID,
|
||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||
@Assisted val seriesPageType: SeriesPageType,
|
||||
|
|
@ -107,6 +110,7 @@ class SeriesViewModel
|
|||
val similar = MutableLiveData<List<BaseItem>>()
|
||||
|
||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||
|
||||
|
|
@ -210,6 +214,10 @@ class SeriesViewModel
|
|||
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,
|
||||
) {
|
||||
item?.let {
|
||||
val isEpisode = item.type == BaseItemKind.EPISODE
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
item.title?.let {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
|
|
@ -466,28 +493,21 @@ fun HomePageHeader(
|
|||
.fillMaxWidth(.6f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
val isEpisode = item.type == BaseItemKind.EPISODE
|
||||
val subtitle = if (isEpisode) dto.name else null
|
||||
val overview = dto.overview
|
||||
subtitle?.let {
|
||||
EpisodeName(it)
|
||||
}
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
|
||||
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
|
||||
else -> MovieQuickDetails(dto, Modifier)
|
||||
}
|
||||
quickDetails?.invoke()
|
||||
val overviewModifier =
|
||||
Modifier
|
||||
.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)
|
||||
if (overview.isNotNullOrBlank()) {
|
||||
Text(
|
||||
text = overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (isEpisode) 2 else 3,
|
||||
maxLines = if (overviewTwoLines) 2 else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = overviewModifier,
|
||||
)
|
||||
|
|
@ -497,4 +517,3 @@ fun HomePageHeader(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
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.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
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.ItemRow
|
||||
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.ifElse
|
||||
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.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -72,11 +80,13 @@ class SearchViewModel
|
|||
constructor(
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val seerrService: SeerrService,
|
||||
) : ViewModel() {
|
||||
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
|
||||
private var currentQuery: String? = null
|
||||
|
||||
|
|
@ -94,11 +104,13 @@ class SearchViewModel
|
|||
searchInternal(query, BaseItemKind.SERIES, series)
|
||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||
searchSeerr(query)
|
||||
} else {
|
||||
movies.value = SearchResult.NoQuery
|
||||
series.value = SearchResult.NoQuery
|
||||
episodes.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) {
|
||||
// TODO
|
||||
// api.searchApi.getSearchHints()
|
||||
|
|
@ -150,12 +176,17 @@ sealed interface SearchResult {
|
|||
data class Success(
|
||||
val items: List<BaseItem?>,
|
||||
) : SearchResult
|
||||
|
||||
data class SuccessSeerr(
|
||||
val items: List<DiscoverItem>,
|
||||
) : SearchResult
|
||||
}
|
||||
|
||||
private const val MOVIE_ROW = 0
|
||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
||||
private const val EPISODE_ROW = SERIES_ROW + 1
|
||||
private const val SEERR_ROW = EPISODE_ROW + 1
|
||||
|
||||
@Composable
|
||||
fun SearchPage(
|
||||
|
|
@ -170,6 +201,7 @@ fun SearchPage(
|
|||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||
|
||||
// val query = rememberTextFieldState()
|
||||
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,
|
||||
onClickPosition: (RowColumn) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null,
|
||||
cardContent: @Composable (
|
||||
index: Int,
|
||||
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 com.github.damontecres.wholphin.data.model.BaseItem
|
||||
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.ItemPlayback
|
||||
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.preferences.PreferenceScreenOption
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -45,7 +45,6 @@ sealed class Destination(
|
|||
data class SeriesOverview(
|
||||
val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val seasonEpisode: SeasonEpisodeIds? = null,
|
||||
) : Destination() {
|
||||
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
|
||||
|
|
@ -55,11 +54,9 @@ sealed class Destination(
|
|||
data class MediaItem(
|
||||
val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val seasonEpisode: SeasonEpisode? = null,
|
||||
val collectionType: CollectionType? = null,
|
||||
) : Destination() {
|
||||
override fun toString(): String =
|
||||
"MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})"
|
||||
constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -71,13 +68,10 @@ sealed class Destination(
|
|||
data class Playback(
|
||||
val itemId: UUID,
|
||||
val positionMs: Long,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val forceTranscoding: Boolean = false,
|
||||
) : Destination(true) {
|
||||
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
||||
|
||||
constructor(item: BaseItem) : this(item.id, item.resumeMs, item)
|
||||
constructor(item: BaseItem) : this(item.id, item.resumeMs)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -109,6 +103,14 @@ sealed class Destination(
|
|||
@Serializable
|
||||
data object Favorites : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data object Discover : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data class DiscoveredItem(
|
||||
val item: DiscoverItem,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
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.ui.components.ItemGrid
|
||||
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.PersonPage
|
||||
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.movie.MovieDetails
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||
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.SearchPage
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||
|
|
@ -121,7 +127,6 @@ fun DestinationContent(
|
|||
CollectionFolderBoxSet(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
item = destination.item,
|
||||
recursive = false,
|
||||
playEnabled = true,
|
||||
modifier = modifier,
|
||||
|
|
@ -141,7 +146,7 @@ fun DestinationContent(
|
|||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
collectionType = destination.item?.data?.collectionType,
|
||||
collectionType = destination.collectionType,
|
||||
usePostersOverride = null,
|
||||
recursiveOverride = null,
|
||||
modifier = modifier,
|
||||
|
|
@ -153,7 +158,7 @@ fun DestinationContent(
|
|||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
collectionType = destination.item?.data?.collectionType,
|
||||
collectionType = destination.collectionType,
|
||||
usePostersOverride = true,
|
||||
recursiveOverride = null,
|
||||
modifier = modifier,
|
||||
|
|
@ -165,7 +170,7 @@ fun DestinationContent(
|
|||
CollectionFolder(
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
collectionType = destination.item?.data?.collectionType,
|
||||
collectionType = destination.collectionType,
|
||||
usePostersOverride = null,
|
||||
recursiveOverride = true,
|
||||
modifier = modifier,
|
||||
|
|
@ -247,6 +252,47 @@ fun DestinationContent(
|
|||
Destination.Debug -> {
|
||||
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(
|
||||
preferences,
|
||||
destination.itemId,
|
||||
destination.item,
|
||||
true,
|
||||
modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors
|
|||
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.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -109,17 +112,25 @@ class NavDrawerViewModel
|
|||
val navigationManager: NavigationManager,
|
||||
val setupNavigationManager: SetupNavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) : ViewModel() {
|
||||
private var all: List<NavDrawerItem>? = null
|
||||
// private var all: List<NavDrawerItem>? = null
|
||||
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
||||
val selectedIndex = MutableLiveData(-1)
|
||||
val showMore = MutableLiveData(false)
|
||||
|
||||
init {
|
||||
seerrServerRepository.active
|
||||
.onEach {
|
||||
init()
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
viewModelScope.launchIO {
|
||||
val all = all ?: navDrawerItemRepository.getNavDrawerItems()
|
||||
this@NavDrawerViewModel.all = all
|
||||
val all = navDrawerItemRepository.getNavDrawerItems()
|
||||
// this@NavDrawerViewModel.all = all
|
||||
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
|
||||
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
|
||||
|
||||
|
|
@ -128,11 +139,19 @@ class NavDrawerViewModel
|
|||
this@NavDrawerViewModel.libraries.value = libraries
|
||||
}
|
||||
val asDestinations =
|
||||
(libraries + listOf(NavDrawerItem.More) + moreLibraries).map {
|
||||
(
|
||||
libraries +
|
||||
listOf(
|
||||
NavDrawerItem.More,
|
||||
NavDrawerItem.Discover,
|
||||
) + moreLibraries
|
||||
).map {
|
||||
if (it is ServerNavDrawerItem) {
|
||||
it.destination
|
||||
} else if (it is NavDrawerItem.Favorites) {
|
||||
Destination.Favorites
|
||||
} else if (it is NavDrawerItem.Discover) {
|
||||
Destination.Discover
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -155,7 +174,7 @@ class NavDrawerViewModel
|
|||
null
|
||||
}
|
||||
}
|
||||
// Timber.v("Found $index => $key")
|
||||
Timber.v("Found $index => $key")
|
||||
if (index != null) {
|
||||
selectedIndex.setValueOnMain(index)
|
||||
break
|
||||
|
|
@ -192,6 +211,13 @@ sealed interface NavDrawerItem {
|
|||
|
||||
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(
|
||||
|
|
@ -267,6 +293,13 @@ fun NavDrawer(
|
|||
setShowMore(!showMore)
|
||||
}
|
||||
|
||||
NavDrawerItem.Discover -> {
|
||||
viewModel.setIndex(index)
|
||||
viewModel.navigationManager.navigateToFromDrawer(
|
||||
Destination.Discover,
|
||||
)
|
||||
}
|
||||
|
||||
is ServerNavDrawerItem -> {
|
||||
viewModel.setIndex(index)
|
||||
viewModel.navigationManager.navigateToFromDrawer(item.destination)
|
||||
|
|
@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem(
|
|||
R.string.fa_ellipsis
|
||||
}
|
||||
|
||||
NavDrawerItem.Discover -> {
|
||||
R.string.fa_magnifying_glass_plus
|
||||
}
|
||||
|
||||
is ServerNavDrawerItem -> {
|
||||
when (library.type) {
|
||||
CollectionType.MOVIES -> R.string.fa_film
|
||||
|
|
|
|||
|
|
@ -241,13 +241,7 @@ class PlaybackViewModel
|
|||
"Error preparing for playback for $itemId",
|
||||
),
|
||||
) {
|
||||
val destItem = (destination as? Destination.Playback)?.item?.data
|
||||
val queriedItem =
|
||||
if (destItem?.mediaSources != null) {
|
||||
destItem
|
||||
} else {
|
||||
api.userLibraryApi.getItem(itemId).content
|
||||
}
|
||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||
val base =
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import coil3.SingletonImageLoader
|
||||
import coil3.imageLoader
|
||||
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.AppPreferences
|
||||
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.updatePlaybackPreferences
|
||||
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.isNotNullOrBlank
|
||||
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.SubtitleStylePage
|
||||
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.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -71,6 +77,7 @@ fun PreferencesContent(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: PreferencesViewModel = hiltViewModel(),
|
||||
updateVM: UpdateViewModel = hiltViewModel(),
|
||||
seerrVm: SwitchSeerrViewModel = hiltViewModel(),
|
||||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -84,6 +91,8 @@ fun PreferencesContent(
|
|||
|
||||
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
|
||||
var showSeerrServerDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
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 -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
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.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
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.services.BackdropService
|
||||
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.launchIO
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
|
|
@ -43,6 +46,7 @@ class PreferencesViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val clientInfo: ClientInfo,
|
||||
) : ViewModel(),
|
||||
|
|
@ -52,6 +56,11 @@ class PreferencesViewModel
|
|||
|
||||
val currentUser get() = serverRepository.currentUser
|
||||
|
||||
val seerrEnabled =
|
||||
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
|
||||
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
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
|
||||
*
|
||||
* @see DataLoadingState
|
||||
*/
|
||||
sealed interface LoadingState {
|
||||
data object Pending : LoadingState
|
||||
|
|
@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState {
|
|||
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_filter" 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>
|
||||
|
|
|
|||
|
|
@ -253,6 +253,22 @@
|
|||
<item quantity="other">%d seconds</item>
|
||||
</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 -->
|
||||
<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>
|
||||
|
|
@ -413,6 +429,18 @@
|
|||
<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="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">
|
||||
<item>Disabled</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"
|
||||
hiltWork = "1.3.0"
|
||||
kotlin = "2.3.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
ksp = "2.3.0"
|
||||
coreKtx = "1.17.0"
|
||||
appcompat = "1.7.1"
|
||||
composeBom = "2025.12.01"
|
||||
mockk = "1.14.7"
|
||||
multiplatformMarkdownRenderer = "0.39.0"
|
||||
okhttpBom = "5.3.2"
|
||||
programguide = "1.6.0"
|
||||
slf4j2Timber = "1.2"
|
||||
timber = "5.0.1"
|
||||
|
|
@ -38,6 +40,7 @@ preferenceKtx = "1.2.1"
|
|||
tvprovider = "1.1.0"
|
||||
workRuntimeKtx = "2.11.0"
|
||||
paletteKtx = "1.0.0"
|
||||
openapi-generator = "7.18.0"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", 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-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
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" }
|
||||
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" }
|
||||
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" }
|
||||
|
|
@ -127,3 +133,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
|
|||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
room = { id = "androidx.room", version.ref = "room" }
|
||||
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()
|
||||
gradlePluginPortal()
|
||||
// maven {
|
||||
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
|
||||
// }
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
|
|
@ -19,9 +16,6 @@ dependencyResolutionManagement {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// maven {
|
||||
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue