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

![discover](https://github.com/user-attachments/assets/f830b48f-e2f1-43f4-9680-9c6d4e071765)

### 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:
Ray 2026-01-12 16:35:27 -05:00 committed by GitHub
parent f4e1b0e171
commit 4c7c465c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 13369 additions and 137 deletions

View file

@ -15,6 +15,7 @@ plugins {
alias(libs.plugins.protobuf) alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.aboutLibraries) alias(libs.plugins.aboutLibraries)
alias(libs.plugins.openapi.generator)
} }
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
@ -145,6 +146,12 @@ android {
isUniversalApk = true isUniversalApk = true
} }
} }
sourceSets {
getByName("main") {
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
}
}
} }
protobuf { protobuf {
@ -176,6 +183,33 @@ aboutLibraries {
} }
} }
openApiGenerate {
generatorName.set("kotlin")
inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml")
templateDir.set("$projectDir/src/main/seerr/templates")
outputDir.set("$buildDir/generated/seerr_api")
apiPackage.set("com.github.damontecres.wholphin.api.seerr")
modelPackage.set("com.github.damontecres.wholphin.api.seerr.model")
groupId.set("com.github.damontecres.wholphin.api.seerr")
id.set("seerr-api")
packageName.set("com.github.damontecres.wholphin.api.seerr")
additionalProperties.apply {
put("serializationLibrary", "kotlinx_serialization")
put("sortModelPropertiesByRequiredFlag", true)
put("sortParamsByRequiredFlag", true)
put("useCoroutines", true)
put("enumPropertyNaming", "UPPERCASE")
put("modelMutable", false)
// Note: this is only for downloading files, so it's not necessary to enable
put("supportAndroidApiLevel25AndBelow", false)
}
}
tasks.named("preBuild") {
dependsOn.add(tasks.named("openApiGenerate"))
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
@ -246,6 +280,8 @@ dependencies {
implementation(libs.acra.limiter) implementation(libs.acra.limiter)
compileOnly(libs.auto.service.annotations) compileOnly(libs.auto.service.annotations)
ksp(libs.auto.service.ksp) ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

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

View file

@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
@ -105,6 +106,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var refreshRateService: RefreshRateService lateinit var refreshRateService: RefreshRateService
@Inject
lateinit var userSwitchListener: UserSwitchListener
@Inject @Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService lateinit var tvProviderSchedulerService: TvProviderSchedulerService

View file

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

View file

@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -32,8 +34,10 @@ import java.util.UUID
LibraryDisplayInfo::class, LibraryDisplayInfo::class,
PlaybackLanguageChoice::class, PlaybackLanguageChoice::class,
ItemTrackModification::class, ItemTrackModification::class,
SeerrServer::class,
SeerrUser::class,
], ],
version = 12, version = 20,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -45,6 +49,7 @@ import java.util.UUID
AutoMigration(9, 10), AutoMigration(9, 10),
AutoMigration(10, 11), AutoMigration(10, 11),
AutoMigration(11, 12), AutoMigration(11, 12),
AutoMigration(12, 20),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
abstract fun seerrServerDao(): SeerrServerDao
} }
class Converters { class Converters {

View file

@ -4,11 +4,13 @@ import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -24,6 +26,7 @@ class NavDrawerItemRepository
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) { ) {
suspend fun getNavDrawerItems(): List<NavDrawerItem> { suspend fun getNavDrawerItems(): List<NavDrawerItem> {
val user = serverRepository.currentUser.value val user = serverRepository.currentUser.value
@ -46,7 +49,13 @@ class NavDrawerItemRepository
setOf() setOf()
} }
val builtins = listOf(NavDrawerItem.Favorites) val builtins =
if (seerrServerRepository.active.first()) {
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
} else {
listOf(NavDrawerItem.Favorites)
}
val libraries = val libraries =
userViews userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }

View file

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

View file

@ -23,7 +23,9 @@ data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
) : CardGridItem { ) : CardGridItem {
override val id get() = data.id val id get() = data.id
override val gridId get() = id.toString()
override val playable: Boolean override val playable: Boolean
get() = type.playable get() = type.playable
@ -88,23 +90,21 @@ data class BaseItem(
Destination.SeriesOverview( Destination.SeriesOverview(
data.seriesId!!, data.seriesId!!,
BaseItemKind.SERIES, BaseItemKind.SERIES,
this,
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
) )
} ?: Destination.MediaItem(id, type, this) } ?: Destination.MediaItem(this)
} }
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
Destination.SeriesOverview( Destination.SeriesOverview(
data.seriesId!!, data.seriesId!!,
BaseItemKind.SERIES, BaseItemKind.SERIES,
this,
SeasonEpisodeIds(id, indexNumber, null, null), SeasonEpisodeIds(id, indexNumber, null, null),
) )
} }
else -> { else -> {
Destination.MediaItem(id, type, this) Destination.MediaItem(this)
} }
} }
return result return result

View file

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

View file

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

View file

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

View file

@ -871,6 +871,13 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val SeerrIntegration =
AppClickablePreference<AppPreferences>(
title = R.string.seerr_integration,
getter = { },
setter = { prefs, _ -> prefs },
)
} }
} }
@ -932,6 +939,7 @@ val basicPreferences =
title = R.string.more, title = R.string.more,
preferences = preferences =
listOf( listOf(
AppPreference.SeerrIntegration,
AppPreference.AdvancedSettings, AppPreference.AdvancedSettings,
), ),
), ),

View file

@ -15,6 +15,7 @@ import coil3.request.SuccessResult
import coil3.request.allowHardware import coil3.request.allowHardware
import coil3.request.bitmapConfig import coil3.request.bitmapConfig
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.BackdropStyle
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -47,15 +47,24 @@ class BackdropService
suspend fun submit(item: BaseItem) = suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
submit(item.id.toString(), imageUrl)
}
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
suspend fun submit(
itemId: String,
imageUrl: String?,
) = withContext(Dispatchers.IO) {
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
_backdropFlow.update { _backdropFlow.update {
it.copy( it.copy(
itemId = item.id, itemId = itemId,
imageUrl = null, imageUrl = null,
) )
} }
extractColors(item) extractColors(itemId, imageUrl)
} }
} }
@ -65,9 +74,11 @@ class BackdropService
} }
} }
private suspend fun extractColors(item: BaseItem) { private suspend fun extractColors(
itemId: String,
imageUrl: String?,
) {
delay(500) delay(500)
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
val backdropStyle = val backdropStyle =
preferences.data preferences.data
.firstOrNull() .firstOrNull()
@ -83,9 +94,9 @@ class BackdropService
ExtractedColors.DEFAULT ExtractedColors.DEFAULT
} }
_backdropFlow.update { _backdropFlow.update {
if (it.itemId == item.id) { if (it.itemId == itemId) {
BackdropResult( BackdropResult(
itemId = item.id, itemId = itemId,
imageUrl = imageUrl, imageUrl = imageUrl,
primaryColor = primaryColor, primaryColor = primaryColor,
secondaryColor = secondaryColor, secondaryColor = secondaryColor,
@ -208,7 +219,7 @@ class BackdropService
} }
data class BackdropResult( data class BackdropResult(
val itemId: UUID?, val itemId: String?,
val imageUrl: String?, val imageUrl: String?,
val primaryColor: Color = Color.Unspecified, val primaryColor: Color = Color.Unspecified,
val secondaryColor: Color = Color.Unspecified, val secondaryColor: Color = Color.Unspecified,

View file

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

View file

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

View file

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

View file

@ -88,7 +88,6 @@ class TrailerService
navigateTo.invoke( navigateTo.invoke(
Destination.Playback( Destination.Playback(
itemId = trailer.baseItem.id, itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L, positionMs = 0L,
), ),
) )

View file

@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.headersContentLength
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@ -211,7 +210,7 @@ class UpdateChecker
if (it.isSuccessful && it.body != null) { if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}") Timber.v("Request successful for ${release.downloadUrl}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
callback.contentLength(it.headersContentLength()) callback.contentLength(it.body.contentLength())
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = val contentValues =

View file

@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
@ -174,4 +175,10 @@ object AppModule {
@Singleton @Singleton
@IoCoroutineScope @IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
} }

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -61,6 +62,10 @@ object DatabaseModule {
@Singleton @Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -4,9 +4,11 @@ import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
@ -23,6 +25,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime)
fun toLocalDate(date: String?): LocalDate? =
date?.let {
try {
LocalDate.parse(it)
} catch (_: DateTimeParseException) {
Timber.w("Could not parse date: %s", date)
null
}
}
/** /**
* If the item has season & episode info, format as `S# E#` * If the item has season & episode info, format as `S# E#`
*/ */

View file

@ -21,8 +21,7 @@ val LocalImageUrlService =
/** /**
* Colors not associated with the theme * Colors not associated with the theme
*/ */
sealed class AppColors private constructor() { object AppColors {
companion object {
val TransparentBlack25 = Color(0x40000000) val TransparentBlack25 = Color(0x40000000)
val TransparentBlack50 = Color(0x80000000) val TransparentBlack50 = Color(0x80000000)
val TransparentBlack75 = Color(0xBF000000) val TransparentBlack75 = Color(0xBF000000)
@ -33,6 +32,12 @@ sealed class AppColors private constructor() {
val DarkPurple = Color(0xFF261370) val DarkPurple = Color(0xFF261370)
val GoldenYellow = Color(0xFFDAB440) val GoldenYellow = Color(0xFFDAB440)
object Discover {
val Blue = Color(37, 99, 235)
val Purple = Color(147, 51, 234)
val Green = Color(74, 222, 128)
val Yellow = Color(234, 179, 8)
} }
} }

View file

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

View file

@ -16,11 +16,9 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
@ -35,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import timber.log.Timber
import java.util.UUID import java.util.UUID
@Composable @Composable

View file

@ -173,11 +173,12 @@ class GenreViewModel
} }
data class Genre( data class Genre(
override val id: UUID, val id: UUID,
val name: String, val name: String,
val imageUrl: String?, val imageUrl: String?,
val color: Color, val color: Color,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false override val playable: Boolean = false
override val sortName: String get() = name override val sortName: String get() = name
} }

View file

@ -179,10 +179,12 @@ fun ExpandablePlayButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false, mirrorIcon: Boolean = false,
enabled: Boolean = true,
) { ) {
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
Button( Button(
onClick = { onClick.invoke(resume) }, onClick = { onClick.invoke(resume) },
enabled = enabled,
modifier = modifier =
modifier.requiredSizeIn( modifier.requiredSizeIn(
minWidth = MinButtonSize, minWidth = MinButtonSize,
@ -234,6 +236,7 @@ fun ExpandableFaButton(
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = modifier =
modifier.requiredSizeIn( modifier.requiredSizeIn(
minWidth = MinButtonSize, minWidth = MinButtonSize,
@ -242,7 +245,6 @@ fun ExpandableFaButton(
), ),
contentPadding = DefaultButtonPadding, contentPadding = DefaultButtonPadding,
interactionSource = interactionSource, interactionSource = interactionSource,
enabled = enabled,
) { ) {
Box( Box(
modifier = modifier =

View file

@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID
private const val DEBUG = false private const val DEBUG = false
interface CardGridItem { interface CardGridItem {
val id: UUID val gridId: String
val playable: Boolean val playable: Boolean
val sortName: String val sortName: String
} }
@ -244,7 +243,7 @@ fun <T : CardGridItem> CardGrid(
} else if (isPlayKeyUp(it)) { } else if (isPlayKeyUp(it)) {
val item = pager.getOrNull(focusedIndex) val item = pager.getOrNull(focusedIndex)
if (item?.playable == true) { if (item?.playable == true) {
Timber.v("Clicked play on ${item.id}") Timber.v("Clicked play on ${item.gridId}")
onClickPlay.invoke(focusedIndex, item) onClickPlay.invoke(focusedIndex, item)
} }
return@onKeyEvent true return@onKeyEvent true
@ -375,7 +374,7 @@ fun <T : CardGridItem> CardGrid(
pager pager
.getOrNull(focusedIndex) .getOrNull(focusedIndex)
?.sortName ?.sortName
?.first() ?.firstOrNull()
?.uppercaseChar() ?.uppercaseChar()
?.let { ?.let {
if (it >= '0' && it <= '9') { if (it >= '0' && it <= '9') {

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -25,7 +24,6 @@ import java.util.UUID
fun CollectionFolderBoxSet( fun CollectionFolderBoxSet(
preferences: UserPreferences, preferences: UserPreferences,
itemId: UUID, itemId: UUID,
item: BaseItem?,
recursive: Boolean, recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),

View file

@ -62,7 +62,7 @@ fun CollectionFolderMovie(
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() } val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } // LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex) logTab("movie", selectedTabIndex)

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -22,7 +21,6 @@ import java.util.UUID
fun CollectionFolderPlaylist( fun CollectionFolderPlaylist(
preferences: UserPreferences, preferences: UserPreferences,
itemId: UUID, itemId: UUID,
item: BaseItem?,
recursive: Boolean, recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),

View file

@ -78,7 +78,6 @@ fun buildMoreDialogItems(
Destination.Playback( Destination.Playback(
item.id, item.id,
item.resumeMs ?: 0L, item.resumeMs ?: 0L,
item,
), ),
) )
}, },
@ -166,6 +165,7 @@ fun buildMoreDialogItems(
Destination.MediaItem( Destination.MediaItem(
seriesId, seriesId,
BaseItemKind.SERIES, BaseItemKind.SERIES,
null,
), ),
) )
}, },
@ -200,7 +200,6 @@ fun buildMoreDialogItems(
Destination.Playback( Destination.Playback(
item.id, item.id,
item.resumeMs ?: 0L, item.resumeMs ?: 0L,
item,
forceTranscoding = true, forceTranscoding = true,
), ),
) )
@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome(
Destination.MediaItem( Destination.MediaItem(
it, it,
BaseItemKind.SERIES, BaseItemKind.SERIES,
null,
), ),
) )
}, },
@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson(
context.getString(R.string.go_to), context.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null))
}, },
) )
add( add(

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -57,6 +60,8 @@ import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.formatDate import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -67,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RowLoadingState import com.github.damontecres.wholphin.util.RowLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -90,10 +98,12 @@ class PersonViewModel
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val seerrService: SeerrService,
) : LoadingItemViewModel(api) { ) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
fun init(itemId: UUID) { fun init(itemId: UUID) {
viewModelScope.launchIO( viewModelScope.launchIO(
@ -119,6 +129,10 @@ class PersonViewModel
} else { } else {
episodes.setValueOnMain(RowLoadingState.Success(listOf())) episodes.setValueOnMain(RowLoadingState.Success(listOf()))
} }
viewModelScope.launchIO {
val results = seerrService.similar(person).orEmpty()
discovered.update { results }
}
} }
} }
} }
@ -181,6 +195,7 @@ fun PersonPage(
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
val series by viewModel.series.observeAsState(RowLoadingState.Pending) val series by viewModel.series.observeAsState(RowLoadingState.Pending)
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
val discovered by viewModel.discovered.collectAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
@ -219,6 +234,10 @@ fun PersonPage(
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite(!person.favorite) viewModel.setFavorite(!person.favorite)
}, },
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination)
},
modifier = modifier, modifier = modifier,
) )
AnimatedVisibility(showOverviewDialog) { AnimatedVisibility(showOverviewDialog) {
@ -243,6 +262,7 @@ private const val HEADER_ROW = 0
private const val MOVIE_ROW = 1 private const val MOVIE_ROW = 1
private const val SERIES_ROW = 2 private const val SERIES_ROW = 2
private const val EPISODE_ROW = 3 private const val EPISODE_ROW = 3
private const val DISCOVER_ROW = EPISODE_ROW + 1
@Composable @Composable
fun PersonPageContent( fun PersonPageContent(
@ -257,9 +277,11 @@ fun PersonPageContent(
movies: RowLoadingState, movies: RowLoadingState,
series: RowLoadingState, series: RowLoadingState,
episodes: RowLoadingState, episodes: RowLoadingState,
discovered: List<DiscoverItem>,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -359,6 +381,24 @@ fun PersonPageContent(
}, },
) )
} }
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = RowColumn(DISCOVER_ROW, index)
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequester,
)
}
}
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -130,7 +130,6 @@ fun EpisodeDetails(
Destination.Playback( Destination.Playback(
ep.id, ep.id,
it.inWholeMilliseconds, it.inWholeMilliseconds,
ep,
), ),
) )
}, },

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.data.model.aspectRatioFloat
@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -99,6 +104,7 @@ fun MovieDetails(
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val discovered by viewModel.discovered.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -168,7 +174,6 @@ fun MovieDetails(
Destination.Playback( Destination.Playback(
movie.id, movie.id,
it.inWholeMilliseconds, it.inWholeMilliseconds,
movie,
), ),
) )
}, },
@ -300,6 +305,10 @@ fun MovieDetails(
onClickExtra = { index, extra -> onClickExtra = { index, extra ->
viewModel.navigateTo(extra.destination) viewModel.navigateTo(extra.destination)
}, },
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
modifier = modifier, modifier = modifier,
) )
} }
@ -360,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1
private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val DISCOVER_ROW = SIMILAR_ROW + 1
@Composable @Composable
fun MovieDetailsContent( fun MovieDetailsContent(
@ -371,6 +381,7 @@ fun MovieDetailsContent(
trailers: List<Trailer>, trailers: List<Trailer>,
extras: List<ExtrasItem>, extras: List<ExtrasItem>,
similar: List<BaseItem>, similar: List<BaseItem>,
discovered: List<DiscoverItem>,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
@ -382,12 +393,13 @@ fun MovieDetailsContent(
onLongClickPerson: (Int, Person) -> Unit, onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var position by rememberInt(0) var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
val dto = movie.data val dto = movie.data
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
@ -543,6 +555,24 @@ fun MovieDetailsContent(
) )
} }
} }
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequesters[DISCOVER_ROW],
)
}
}
} }
} }
} }

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -54,6 +58,7 @@ class MovieViewModel
@AssistedInject @AssistedInject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val seerrService: SeerrService,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
@ -81,6 +86,7 @@ class MovieViewModel
val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
init { init {
init() init()
@ -140,6 +146,10 @@ class MovieViewModel
val extras = extrasService.getExtras(item.id) val extras = extrasService.getExtras(item.id)
this@MovieViewModel.extras.setValueOnMain(extras) this@MovieViewModel.extras.setValueOnMain(extras)
} }
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chapters.value = Chapter.fromDto(item.data, api) chapters.value = Chapter.fromDto(item.data, api)

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -40,6 +41,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -71,9 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -104,6 +109,7 @@ fun SeriesDetails(
val extras by viewModel.extras.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) } var showWatchConfirmation by remember { mutableStateOf(false) }
@ -208,6 +214,10 @@ fun SeriesDetails(
onClickExtra = { _, extra -> onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination) viewModel.navigateTo(extra.destination)
}, },
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
moreActions = moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) }, navigateTo = { viewModel.navigateTo(it) },
@ -281,6 +291,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val EXTRAS_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = TRAILER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1
private const val DISCOVER_ROW = SIMILAR_ROW + 1
@Composable @Composable
fun SeriesDetailsContent( fun SeriesDetailsContent(
@ -291,6 +302,7 @@ fun SeriesDetailsContent(
trailers: List<Trailer>, trailers: List<Trailer>,
extras: List<ExtrasItem>, extras: List<ExtrasItem>,
people: List<Person>, people: List<Person>,
discovered: List<DiscoverItem>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
@ -303,6 +315,7 @@ fun SeriesDetailsContent(
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions, moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -310,7 +323,7 @@ fun SeriesDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt() var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
RequestOrRestoreFocus(focusRequesters.getOrNull(position)) RequestOrRestoreFocus(focusRequesters.getOrNull(position))
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -546,6 +559,24 @@ fun SeriesDetailsContent(
) )
} }
} }
if (discovered.isNotEmpty()) {
item {
DiscoverRow(
row =
DiscoverRowData(
stringResource(R.string.discover),
DataLoadingState.Success(discovered),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = DISCOVER_ROW
onClickDiscover.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequesters[DISCOVER_ROW],
)
}
}
} }
} }
} }

View file

@ -312,7 +312,6 @@ fun SeriesOverview(
Destination.Playback( Destination.Playback(
it.id, it.id,
resumePosition.inWholeMilliseconds, resumePosition.inWholeMilliseconds,
it,
), ),
) )
}, },
@ -327,7 +326,6 @@ fun SeriesOverview(
Destination.Playback( Destination.Playback(
it.id, it.id,
resume.inWholeMilliseconds, resume.inWholeMilliseconds,
it,
), ),
) )
} }

View file

@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
@ -84,6 +86,7 @@ class SeriesViewModel
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val seerrService: SeerrService,
@Assisted val seriesId: UUID, @Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType, @Assisted val seriesPageType: SeriesPageType,
@ -107,6 +110,7 @@ class SeriesViewModel
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -210,6 +214,10 @@ class SeriesViewModel
this@SeriesViewModel.similar.setValueOnMain(similar) this@SeriesViewModel.similar.setValueOnMain(similar)
} }
} }
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -444,12 +444,39 @@ fun HomePageHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
item?.let { item?.let {
val isEpisode = item.type == BaseItemKind.EPISODE
val dto = item.data val dto = item.data
HomePageHeader(
title = item.title,
subtitle = if (isEpisode) dto.name else null,
overview = dto.overview,
overviewTwoLines = isEpisode,
quickDetails = {
when (item.type) {
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
else -> MovieQuickDetails(dto, Modifier)
}
},
modifier = modifier,
)
}
}
@Composable
fun HomePageHeader(
title: String?,
subtitle: String?,
overview: String?,
overviewTwoLines: Boolean,
quickDetails: (@Composable () -> Unit)?,
modifier: Modifier = Modifier,
) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
item.title?.let { title?.let {
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
@ -466,28 +493,21 @@ fun HomePageHeader(
.fillMaxWidth(.6f) .fillMaxWidth(.6f)
.fillMaxHeight(), .fillMaxHeight(),
) { ) {
val isEpisode = item.type == BaseItemKind.EPISODE
val subtitle = if (isEpisode) dto.name else null
val overview = dto.overview
subtitle?.let { subtitle?.let {
EpisodeName(it) EpisodeName(it)
} }
when (item.type) { quickDetails?.invoke()
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
else -> MovieQuickDetails(dto, Modifier)
}
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)
.height(48.dp + if (!isEpisode) 12.dp else 0.dp) .height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp)
.width(400.dp) .width(400.dp)
if (overview.isNotNullOrBlank()) { if (overview.isNotNullOrBlank()) {
Text( Text(
text = overview, text = overview,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = if (isEpisode) 2 else 3, maxLines = if (overviewTwoLines) 2 else 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = overviewModifier, modifier = overviewModifier,
) )
@ -496,5 +516,4 @@ fun HomePageHeader(
} }
} }
} }
}
} }

View file

@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -50,7 +54,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,6 +65,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -72,11 +80,13 @@ class SearchViewModel
constructor( constructor(
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val seerrService: SeerrService,
) : ViewModel() { ) : ViewModel() {
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery) val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery) val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null private var currentQuery: String? = null
@ -94,11 +104,13 @@ class SearchViewModel
searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections) searchInternal(query, BaseItemKind.BOX_SET, collections)
searchSeerr(query)
} else { } else {
movies.value = SearchResult.NoQuery movies.value = SearchResult.NoQuery
series.value = SearchResult.NoQuery series.value = SearchResult.NoQuery
episodes.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery
} }
} }
@ -132,6 +144,20 @@ class SearchViewModel
} }
} }
private fun searchSeerr(query: String) {
viewModelScope.launchIO {
if (seerrService.active.first()) {
seerrResults.setValueOnMain(SearchResult.Searching)
val results =
seerrService
.search(query)
.map { DiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
}
}
}
fun getHints(query: String) { fun getHints(query: String) {
// TODO // TODO
// api.searchApi.getSearchHints() // api.searchApi.getSearchHints()
@ -150,12 +176,17 @@ sealed interface SearchResult {
data class Success( data class Success(
val items: List<BaseItem?>, val items: List<BaseItem?>,
) : SearchResult ) : SearchResult
data class SuccessSeerr(
val items: List<DiscoverItem>,
) : SearchResult
} }
private const val MOVIE_ROW = 0 private const val MOVIE_ROW = 0
private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val COLLECTION_ROW = MOVIE_ROW + 1
private const val SERIES_ROW = COLLECTION_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1
private const val SEERR_ROW = EPISODE_ROW + 1
@Composable @Composable
fun SearchPage( fun SearchPage(
@ -170,6 +201,7 @@ fun SearchPage(
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
// val query = rememberTextFieldState() // val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
@ -288,6 +320,30 @@ fun SearchPage(
) )
}, },
) )
searchResultRow(
title = context.getString(R.string.discover),
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { _, _ ->
// no-op
},
onClickDiscover = { _, item ->
val dest =
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = item.type.baseItemKind,
)
} else {
Destination.DiscoveredItem(item)
}
viewModel.navigationManager.navigateTo(dest)
},
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
} }
} }
@ -300,6 +356,7 @@ fun LazyListScope.searchResultRow(
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit, onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null,
cardContent: @Composable ( cardContent: @Composable (
index: Int, index: Int,
item: BaseItem?, item: BaseItem?,
@ -365,6 +422,36 @@ fun LazyListScope.searchResultRow(
) )
} }
} }
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = title,
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow(
title = title,
items = r.items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(rowIndex, index))
onClickDiscover?.invoke(index, item)
},
onLongClickItem = { _, _ -> },
modifier = modifier,
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
)
}
}
} }
} }
} }

View file

@ -6,16 +6,16 @@ import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
@ -45,7 +45,6 @@ sealed class Destination(
data class SeriesOverview( data class SeriesOverview(
val itemId: UUID, val itemId: UUID,
val type: BaseItemKind, val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisodeIds? = null, val seasonEpisode: SeasonEpisodeIds? = null,
) : Destination() { ) : Destination() {
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)" override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
@ -55,11 +54,9 @@ sealed class Destination(
data class MediaItem( data class MediaItem(
val itemId: UUID, val itemId: UUID,
val type: BaseItemKind, val type: BaseItemKind,
@Transient val item: BaseItem? = null, val collectionType: CollectionType? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination() { ) : Destination() {
override fun toString(): String = constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType)
"MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})"
} }
@Serializable @Serializable
@ -71,13 +68,10 @@ sealed class Destination(
data class Playback( data class Playback(
val itemId: UUID, val itemId: UUID,
val positionMs: Long, val positionMs: Long,
@Transient val item: BaseItem? = null,
val itemPlayback: ItemPlayback? = null, val itemPlayback: ItemPlayback? = null,
val forceTranscoding: Boolean = false, val forceTranscoding: Boolean = false,
) : Destination(true) { ) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" constructor(item: BaseItem) : this(item.id, item.resumeMs)
constructor(item: BaseItem) : this(item.id, item.resumeMs, item)
} }
@Serializable @Serializable
@ -109,6 +103,14 @@ sealed class Destination(
@Serializable @Serializable
data object Favorites : Destination(false) data object Favorites : Destination(false)
@Serializable
data object Discover : Destination(false)
@Serializable
data class DiscoveredItem(
val item: DiscoverItem,
) : Destination(false)
@Serializable @Serializable
data object UpdateApp : Destination(true) data object UpdateApp : Destination(true)

View file

@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.components.LicenseInfo
@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage
@ -121,7 +127,6 @@ fun DestinationContent(
CollectionFolderBoxSet( CollectionFolderBoxSet(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
item = destination.item,
recursive = false, recursive = false,
playEnabled = true, playEnabled = true,
modifier = modifier, modifier = modifier,
@ -141,7 +146,7 @@ fun DestinationContent(
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
collectionType = destination.item?.data?.collectionType, collectionType = destination.collectionType,
usePostersOverride = null, usePostersOverride = null,
recursiveOverride = null, recursiveOverride = null,
modifier = modifier, modifier = modifier,
@ -153,7 +158,7 @@ fun DestinationContent(
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
collectionType = destination.item?.data?.collectionType, collectionType = destination.collectionType,
usePostersOverride = true, usePostersOverride = true,
recursiveOverride = null, recursiveOverride = null,
modifier = modifier, modifier = modifier,
@ -165,7 +170,7 @@ fun DestinationContent(
CollectionFolder( CollectionFolder(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
collectionType = destination.item?.data?.collectionType, collectionType = destination.collectionType,
usePostersOverride = null, usePostersOverride = null,
recursiveOverride = true, recursiveOverride = true,
modifier = modifier, modifier = modifier,
@ -247,6 +252,47 @@ fun DestinationContent(
Destination.Debug -> { Destination.Debug -> {
DebugPage(preferences, modifier) DebugPage(preferences, modifier)
} }
Destination.Discover -> {
DiscoverPage(
preferences = preferences,
modifier = modifier,
)
}
is Destination.DiscoveredItem -> {
when (destination.item.type) {
SeerrItemType.MOVIE -> {
DiscoverMovieDetails(
preferences = preferences,
destination = destination,
modifier = modifier,
)
}
SeerrItemType.TV -> {
DiscoverSeriesDetails(
preferences = preferences,
destination = destination,
modifier = modifier,
)
}
SeerrItemType.PERSON -> {
DiscoverPersonPage(
person = destination.item,
modifier = modifier,
)
}
SeerrItemType.UNKNOWN -> {
Text(
text = "Unknown discover type",
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
} }
} }
@ -292,7 +338,6 @@ fun CollectionFolder(
CollectionFolderPlaylist( CollectionFolderPlaylist(
preferences, preferences,
destination.itemId, destination.itemId,
destination.item,
true, true,
modifier, modifier,
) )

View file

@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
@ -94,6 +95,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -109,17 +112,25 @@ class NavDrawerViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager, val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() { ) : ViewModel() {
private var all: List<NavDrawerItem>? = null // private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData(-1) val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false) val showMore = MutableLiveData(false)
init {
seerrServerRepository.active
.onEach {
init()
}.launchIn(viewModelScope)
}
fun init() { fun init() {
viewModelScope.launchIO { viewModelScope.launchIO {
val all = all ?: navDrawerItemRepository.getNavDrawerItems() val all = navDrawerItemRepository.getNavDrawerItems()
this@NavDrawerViewModel.all = all // this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) } val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
@ -128,11 +139,19 @@ class NavDrawerViewModel
this@NavDrawerViewModel.libraries.value = libraries this@NavDrawerViewModel.libraries.value = libraries
} }
val asDestinations = val asDestinations =
(libraries + listOf(NavDrawerItem.More) + moreLibraries).map { (
libraries +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
) + moreLibraries
).map {
if (it is ServerNavDrawerItem) { if (it is ServerNavDrawerItem) {
it.destination it.destination
} else if (it is NavDrawerItem.Favorites) { } else if (it is NavDrawerItem.Favorites) {
Destination.Favorites Destination.Favorites
} else if (it is NavDrawerItem.Discover) {
Destination.Discover
} else { } else {
null null
} }
@ -155,7 +174,7 @@ class NavDrawerViewModel
null null
} }
} }
// Timber.v("Found $index => $key") Timber.v("Found $index => $key")
if (index != null) { if (index != null) {
selectedIndex.setValueOnMain(index) selectedIndex.setValueOnMain(index)
break break
@ -192,6 +211,13 @@ sealed interface NavDrawerItem {
override fun name(context: Context): String = context.getString(R.string.more) override fun name(context: Context): String = context.getString(R.string.more)
} }
object Discover : NavDrawerItem {
override val id: String
get() = "a_discover"
override fun name(context: Context): String = context.getString(R.string.discover)
}
} }
data class ServerNavDrawerItem( data class ServerNavDrawerItem(
@ -267,6 +293,13 @@ fun NavDrawer(
setShowMore(!showMore) setShowMore(!showMore)
} }
NavDrawerItem.Discover -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> { is ServerNavDrawerItem -> {
viewModel.setIndex(index) viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(item.destination) viewModel.navigationManager.navigateToFromDrawer(item.destination)
@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem(
R.string.fa_ellipsis R.string.fa_ellipsis
} }
NavDrawerItem.Discover -> {
R.string.fa_magnifying_glass_plus
}
is ServerNavDrawerItem -> { is ServerNavDrawerItem -> {
when (library.type) { when (library.type) {
CollectionType.MOVIES -> R.string.fa_film CollectionType.MOVIES -> R.string.fa_film

View file

@ -241,13 +241,7 @@ class PlaybackViewModel
"Error preparing for playback for $itemId", "Error preparing for playback for $itemId",
), ),
) { ) {
val destItem = (destination as? Destination.Playback)?.item?.data val queriedItem = api.userLibraryApi.getItem(itemId).content
val queriedItem =
if (destItem?.mediaSources != null) {
destItem
} else {
api.userLibraryApi.getItem(itemId).content
}
val base = val base =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
queriedItem queriedItem

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.imageLoader import coil3.imageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -58,9 +61,12 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -71,6 +77,7 @@ fun PreferencesContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(), viewModel: PreferencesViewModel = hiltViewModel(),
updateVM: UpdateViewModel = hiltViewModel(), updateVM: UpdateViewModel = hiltViewModel(),
seerrVm: SwitchSeerrViewModel = hiltViewModel(),
onFocus: (Int, Int) -> Unit = { _, _ -> }, onFocus: (Int, Int) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -84,6 +91,8 @@ fun PreferencesContent(
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var showSeerrServerDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { viewModel.preferenceDataStore.data.collect {
@ -381,6 +390,22 @@ fun PreferencesContent(
) )
} }
AppPreference.SeerrIntegration -> {
ClickPreference(
title = stringResource(pref.title),
onClick = { showSeerrServerDialog = true },
modifier = Modifier,
summary =
if (seerrIntegrationEnabled) {
stringResource(R.string.enabled)
} else {
null
},
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(
@ -440,6 +465,39 @@ fun PreferencesContent(
) )
} }
} }
if (showSeerrServerDialog) {
if (seerrIntegrationEnabled) {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = "",
onCancel = { showSeerrServerDialog = false },
onConfirm = {
seerrVm.removeServer()
showSeerrServerDialog = false
},
)
} else {
val currentUser by seerrVm.currentUser.observeAsState()
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
LaunchedEffect(status) {
if (status == LoadingState.Success) {
showSeerrServerDialog = false
}
}
AddSeerServerDialog(
currentUsername = currentUser?.name,
status = status,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod ->
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onDismissRequest = { showSeerrServerDialog = false },
)
}
}
} }
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
@ -43,6 +46,7 @@ class PreferencesViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
private val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo, private val clientInfo: ClientInfo,
) : ViewModel(), ) : ViewModel(),
@ -52,6 +56,11 @@ class PreferencesViewModel
val currentUser get() = serverRepository.currentUser val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->

View file

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

View file

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

View file

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

View file

@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
/** /**
* Generic state for loading something from the API * Generic state for loading something from the API
*
* @see DataLoadingState
*/ */
sealed interface LoadingState { sealed interface LoadingState {
data object Pending : LoadingState data object Pending : LoadingState
@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
} }
} }
/**
* Generic state for loading something from the API
*
* @see LoadingState
*/
sealed interface DataLoadingState<out T> {
data object Pending : DataLoadingState<Nothing>
data object Loading : DataLoadingState<Nothing>
data class Success<T>(
val data: T,
) : DataLoadingState<T>
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : DataLoadingState<Nothing> {
constructor(exception: Throwable) : this(null, exception)
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}

View file

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

View file

@ -45,4 +45,8 @@
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string> <string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
<string name="fa_filter" translatable="false">&#xf0b0;</string> <string name="fa_filter" translatable="false">&#xf0b0;</string>
<string name="fa_sliders" translatable="false">&#xf1de;</string> <string name="fa_sliders" translatable="false">&#xf1de;</string>
<string name="fa_download" translatable="false">&#xf019;</string>
<string name="fa_clock" translatable="false">&#xf017;</string>
<string name="fa_bell" translatable="false">&#xf0f3;</string>
<string name="fa_check" translatable="false">&#xf00c;</string>
</resources> </resources>

View file

@ -253,6 +253,22 @@
<item quantity="other">%d seconds</item> <item quantity="other">%d seconds</item>
</plurals> </plurals>
<plurals name="movies">
<item quantity="zero">Movies</item>
<item quantity="one">Movie</item>
<item quantity="other">Movies</item>
</plurals>
<plurals name="tv_shows">
<item quantity="zero">TV Shows</item>
<item quantity="one">TV Show</item>
<item quantity="other">TV Shows</item>
</plurals>
<plurals name="people">
<item quantity="zero">People</item>
<item quantity="one">Person</item>
<item quantity="other">People</item>
</plurals>
<!-- Preferences/Setting specific strings --> <!-- Preferences/Setting specific strings -->
<string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string> <string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string>
<string name="pref_key_update_last_check" translatable="false">preference.update.lastTimestamp</string> <string name="pref_key_update_last_check" translatable="false">preference.update.lastTimestamp</string>
@ -413,6 +429,18 @@
<string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string>
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string> <string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
<string name="discover">Discover</string>
<string name="request">Request</string>
<string name="pending">Pending</string>
<string name="seerr_integration">Seerr integration</string>
<string name="remove_seerr_server">Remove Seerr Server</string>
<string name="password">Password</string>
<string name="username">Username</string>
<string name="url">URL</string>
<string name="trending">Trending</string>
<string name="upcoming_movies">Upcoming Movies</string>
<string name="upcoming_tv">Upcoming TV Shows</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
<item>Lowest</item> <item>Lowest</item>

File diff suppressed because it is too large Load diff

View file

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

View file

@ -9,12 +9,14 @@ hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0" hiltWork = "1.3.0"
kotlin = "2.3.0" kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0" ksp = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.12.01" composeBom = "2025.12.01"
mockk = "1.14.7" mockk = "1.14.7"
multiplatformMarkdownRenderer = "0.39.0" multiplatformMarkdownRenderer = "0.39.0"
okhttpBom = "5.3.2"
programguide = "1.6.0" programguide = "1.6.0"
slf4j2Timber = "1.2" slf4j2Timber = "1.2"
timber = "5.0.1" timber = "5.0.1"
@ -38,6 +40,7 @@ preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.0" workRuntimeKtx = "2.11.0"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
openapi-generator = "7.18.0"
[libraries] [libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@ -73,10 +76,13 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
okhttp = { module = "com.squareup.okhttp3:okhttp" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" }
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
@ -127,3 +133,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" } room = { id = "androidx.room", version.ref = "room" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }
openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" }

View file

@ -9,9 +9,6 @@ pluginManagement {
} }
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
// maven {
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
// }
} }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
@ -19,9 +16,6 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// maven {
// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository")
// }
} }
} }