mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
83e5a44f37
commit
028553dc91
6 changed files with 228 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
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.userApi
|
||||
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 {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(
|
||||
|
|
|
|||
|
|
@ -943,6 +943,14 @@ sealed interface AppPreference<Pref, T> {
|
|||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val QuickConnect =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.quick_connect,
|
||||
summary = R.string.quick_connect_summary,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val SlideshowDuration =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.slideshow_duration,
|
||||
|
|
@ -1168,6 +1176,7 @@ val advancedPreferences =
|
|||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.QuickConnect,
|
||||
AppPreference.SendAppLogs,
|
||||
AppPreference.SendCrashReports,
|
||||
AppPreference.DebugLogging,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ fun PreferencesContent(
|
|||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
|
||||
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
|
||||
var showQuickConnectDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
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 -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
ComposablePreference(
|
||||
|
|
@ -506,6 +523,37 @@ fun PreferencesContent(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
|
|
@ -61,6 +64,9 @@ class PreferencesViewModel
|
|||
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
||||
}
|
||||
|
||||
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
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 {
|
||||
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
|
||||
appPreferences.updateData {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -458,6 +458,11 @@
|
|||
<string name="seerr_integration">Seerr integration</string>
|
||||
<string name="remove_seerr_server">Remove Seerr Server</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="username">Username</string>
|
||||
<string name="url">URL</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue