Add Quick Connect authorization to Settings (#820)

## Description
- Adding `Quick Connect` drawer to the Settings under the Seer
integration, and before Additional options.
- When interacted with, A inline-dialog appears (after internally
resetting itself), with gutter options Cancel / OK, like existing
dialogs, and when that is completed, where it completes the other side
of `Quick Connect`, e.g. login to your phone Jellyfin app by using
Wholphin.
- Failure and success both are indicated to the user and if success, it
closes the window.

### Related issues
Related to #819 
Closes #819

### Testing
Tested on development build from my repo on a Nvidia Shield 2019.
Performed several logout/login, both success and fail to observe dialog
state and function.

## AI or LLM usage
Used Claude Sonnet 4.5 for most of the code, ChatGPT, reviewed it, then
I reviewed it, made a few tweaks.
This commit is contained in:
voc0der 2026-02-12 01:06:03 +00:00 committed by GitHub
parent 83e5a44f37
commit 028553dc91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 228 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
@ -265,6 +266,17 @@ class ServerRepository
} }
} }
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) {
val userId = currentUser.value?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")
throw IllegalStateException("Must be logged in to authorize Quick Connect")
}
val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId)
response.content
}
companion object { companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences = fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences( context.getSharedPreferences(

View file

@ -943,6 +943,14 @@ sealed interface AppPreference<Pref, T> {
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val QuickConnect =
AppClickablePreference<AppPreferences>(
title = R.string.quick_connect,
summary = R.string.quick_connect_summary,
getter = { },
setter = { prefs, _ -> prefs },
)
val SlideshowDuration = val SlideshowDuration =
AppSliderPreference<AppPreferences>( AppSliderPreference<AppPreferences>(
title = R.string.slideshow_duration, title = R.string.slideshow_duration,
@ -1168,6 +1176,7 @@ val advancedPreferences =
title = R.string.more, title = R.string.more,
preferences = preferences =
listOf( listOf(
AppPreference.QuickConnect,
AppPreference.SendAppLogs, AppPreference.SendAppLogs,
AppPreference.SendCrashReports, AppPreference.SendCrashReports,
AppPreference.DebugLogging, AppPreference.DebugLogging,

View file

@ -93,6 +93,7 @@ fun PreferencesContent(
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) val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
var showQuickConnectDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { viewModel.preferenceDataStore.data.collect {
@ -413,6 +414,22 @@ fun PreferencesContent(
) )
} }
AppPreference.QuickConnect -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
if (currentUser != null) {
viewModel.resetQuickConnectStatus()
showQuickConnectDialog = true
}
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(
@ -506,6 +523,37 @@ fun PreferencesContent(
SeerrDialogMode.None -> {} SeerrDialogMode.None -> {}
} }
} }
if (showQuickConnectDialog) {
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
val successMessage = stringResource(R.string.quick_connect_success)
LaunchedEffect(quickConnectStatus) {
when (val status = quickConnectStatus) {
LoadingState.Success -> {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
showQuickConnectDialog = false
}
is LoadingState.Error -> {
val errorMessage = status.message ?: "Authorization failed"
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
}
else -> {}
}
}
QuickConnectDialog(
onSubmit = { code ->
viewModel.authorizeQuickConnect(code)
},
onDismissRequest = {
viewModel.resetQuickConnectStatus()
showQuickConnectDialog = false
},
)
}
} }
@Composable @Composable

View file

@ -24,9 +24,12 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
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.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine 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
@ -61,6 +64,9 @@ class PreferencesViewModel
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
} }
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
@ -123,6 +129,27 @@ class PreferencesViewModel
} }
} }
fun resetQuickConnectStatus() {
_quickConnectStatus.value = LoadingState.Pending
}
fun authorizeQuickConnect(code: String) {
viewModelScope.launchIO {
_quickConnectStatus.value = LoadingState.Loading
try {
val success = serverRepository.authorizeQuickConnect(code)
_quickConnectStatus.value =
if (success) {
LoadingState.Success
} else {
LoadingState.Error("Authorization failed")
}
} catch (e: Exception) {
_quickConnectStatus.value = LoadingState.Error(e)
}
}
}
companion object { companion object {
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) { suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
appPreferences.updateData { appPreferences.updateData {

View file

@ -0,0 +1,127 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
@Composable
fun QuickConnectDialog(
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
elevation: Dp = 3.dp,
) {
var code by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
val isValidCode: (String) -> Boolean = { value ->
val trimmed = value.trim()
trimmed.length == 6 && trimmed.all { it.isDigit() }
}
val onSubmitCode = {
if (isValidCode(code)) {
showError = false
onSubmit(code.trim())
} else {
showError = true
}
}
Dialog(
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
onDismissRequest = onDismissRequest,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(16.dp)
.width(360.dp)
.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
shape = RoundedCornerShape(8.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
Text(
text = stringResource(R.string.quick_connect_code),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
EditTextBox(
value = code,
onValueChange = {
code = it
showError = false
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions =
KeyboardActions(
onDone = {
onSubmitCode()
},
),
isInputValid = { value ->
!showError || isValidCode(value)
},
modifier = Modifier.fillMaxWidth(),
)
if (showError) {
Text(
text = stringResource(R.string.quick_connect_code_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp),
)
}
TextButton(
stringRes = R.string.submit,
onClick = onSubmitCode,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
}

View file

@ -458,6 +458,11 @@
<string name="seerr_integration">Seerr integration</string> <string name="seerr_integration">Seerr integration</string>
<string name="remove_seerr_server">Remove Seerr Server</string> <string name="remove_seerr_server">Remove Seerr Server</string>
<string name="seerr_server_added">Seerr server added</string> <string name="seerr_server_added">Seerr server added</string>
<string name="quick_connect">Quick Connect</string>
<string name="quick_connect_summary">Authorize another device to log in to your account</string>
<string name="quick_connect_code">Enter Quick Connect code</string>
<string name="quick_connect_code_error">Code must be 6 digits</string>
<string name="quick_connect_success">Device authorized successfully</string>
<string name="password">Password</string> <string name="password">Password</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="url">URL</string> <string name="url">URL</string>