feat: redesign login screen on both app and app-tv

This commit is contained in:
2026-05-13 21:59:23 +02:00
parent e69a3a6d4a
commit d3b66d4fc5
12 changed files with 943 additions and 177 deletions

View File

@@ -6,18 +6,25 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.QuickConnectSession
import hu.bbara.purefin.core.data.UserSessionRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
@@ -26,6 +33,8 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -90,6 +99,30 @@ class JellyfinApiClient @Inject constructor(
}
}
fun discoverServers(): Flow<JellyfinServerCandidate> =
jellyfin.discovery.discoverLocalServers()
.map { JellyfinServerCandidate(name = it.name, address = it.address) }
.flowOn(Dispatchers.IO)
suspend fun findServer(input: String): JellyfinServerCandidate? = withContext(Dispatchers.IO) {
logRequest("findServer") {
val address = input.trim()
if (address.isBlank()) {
return@logRequest null
}
val recommendedServers = jellyfin.discovery.getRecommendedServers(
input = address,
minimumScore = RecommendedServerInfoScore.OK
)
val server = recommendedServers.bestServer() ?: return@logRequest null
JellyfinServerCandidate(
name = server.systemInfo.getOrNull()?.serverName,
address = server.address
)
}
}
suspend fun authenticate(
url: String,
username: String,
@@ -106,6 +139,50 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun isQuickConnectEnabled(url: String): Boolean = withContext(Dispatchers.IO) {
logRequest("isQuickConnectEnabled") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) {
return@logRequest false
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.getQuickConnectEnabled().content
}
}
suspend fun initiateQuickConnect(url: String): QuickConnectSession? = withContext(Dispatchers.IO) {
logRequest("initiateQuickConnect") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.initiateQuickConnect().content.toQuickConnectSession()
}
}
suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? = withContext(Dispatchers.IO) {
logRequest("getQuickConnectState") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank() || secret.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.getQuickConnectState(secret).content.toQuickConnectSession()
}
}
suspend fun authenticateWithQuickConnect(url: String, secret: String): AuthenticationResult? = withContext(Dispatchers.IO) {
logRequest("authenticateWithQuickConnect") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank() || secret.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.userApi.authenticateWithQuickConnect(secret).content
}
}
suspend fun searchBySearchTerm(searchTerm: String): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("searchBySearchTerm") {
if (!ensureConfigured()) {
@@ -547,6 +624,18 @@ class JellyfinApiClient @Inject constructor(
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
}
private fun Collection<RecommendedServerInfo>.bestServer(): RecommendedServerInfo? =
firstOrNull { it.score == RecommendedServerInfoScore.GREAT }
?: firstOrNull { it.score == RecommendedServerInfoScore.GOOD }
?: firstOrNull { it.score == RecommendedServerInfoScore.OK }
private fun org.jellyfin.sdk.model.api.QuickConnectResult.toQuickConnectSession(): QuickConnectSession =
QuickConnectSession(
code = code,
secret = secret,
authenticated = authenticated
)
companion object {
private const val TAG = "JellyfinApiClient"
}

View File

@@ -2,8 +2,12 @@ package hu.bbara.purefin.data.jellyfin.session
import android.util.Log
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.QuickConnectSession
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.model.api.AuthenticationResult
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,23 +16,61 @@ class JellyfinAuthenticationRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository,
) : AuthenticationRepository {
override fun discoverServers(): Flow<JellyfinServerCandidate> =
jellyfinApiClient.discoverServers()
override suspend fun findServer(input: String): JellyfinServerCandidate? =
runCatching { jellyfinApiClient.findServer(input) }
.onFailure { Log.e(TAG, "Server search failed", it) }
.getOrNull()
override suspend fun isQuickConnectEnabled(url: String): Boolean =
runCatching { jellyfinApiClient.isQuickConnectEnabled(url) }
.onFailure { Log.e(TAG, "Quick Connect check failed", it) }
.getOrDefault(false)
override suspend fun initiateQuickConnect(url: String): QuickConnectSession? =
runCatching { jellyfinApiClient.initiateQuickConnect(url) }
.onFailure { Log.e(TAG, "Quick Connect initiation failed", it) }
.getOrNull()
override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? =
runCatching { jellyfinApiClient.getQuickConnectState(url, secret) }
.onFailure { Log.e(TAG, "Quick Connect state check failed", it) }
.getOrNull()
override suspend fun login(url: String, username: String, password: String): Boolean {
return try {
val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password)
?: return false
val token = authResult.accessToken ?: return false
val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true)
true
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Login failed", e)
false
}
}
override suspend fun loginWithQuickConnect(url: String, secret: String): Boolean {
return try {
val authResult = jellyfinApiClient.authenticateWithQuickConnect(url = url, secret = secret)
?: return false
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Quick Connect login failed", e)
false
}
}
private suspend fun saveAuthenticationResult(authResult: AuthenticationResult): Boolean {
val token = authResult.accessToken ?: return false
val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true)
return true
}
private companion object {
private const val TAG = "JellyfinAuthRepo"
}