mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into develop/customize-home
This commit is contained in:
commit
290070fbfb
9 changed files with 301 additions and 26 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -949,6 +949,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,
|
||||||
|
|
@ -1172,6 +1180,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,
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,15 @@ fun CoroutineScope.launchIO(
|
||||||
block: suspend CoroutineScope.() -> Unit,
|
block: suspend CoroutineScope.() -> Unit,
|
||||||
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
||||||
|
*/
|
||||||
|
fun CoroutineScope.launchDefault(
|
||||||
|
context: CoroutineContext = ExceptionHandler(),
|
||||||
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
|
block: suspend CoroutineScope.() -> Unit,
|
||||||
|
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a UUID to the format used server-side (ie without hyphens).
|
* Converts a UUID to the format used server-side (ie without hyphens).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -163,8 +162,7 @@ fun PlaybackPageContent(
|
||||||
itemId = UUID.randomUUID(),
|
itemId = UUID.randomUUID(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
@ -302,10 +300,10 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val showSegment =
|
val showSegment =
|
||||||
!segmentCancelled && currentSegment != null &&
|
currentSegment?.interacted == false &&
|
||||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||||
BackHandler(showSegment) {
|
BackHandler(showSegment) {
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(currentSegment?.segment?.id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -400,7 +398,7 @@ fun PlaybackPageContent(
|
||||||
onClickPlaylist = {
|
onClickPlaylist = {
|
||||||
viewModel.playItemInPlaylist(it)
|
viewModel.playItemInPlaylist(it)
|
||||||
},
|
},
|
||||||
currentSegment = currentSegment,
|
currentSegment = currentSegment?.segment,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -462,13 +460,12 @@ fun PlaybackPageContent(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
delay(10.seconds)
|
delay(10.seconds)
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(segment.segment.id, true)
|
||||||
}
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
stringRes = segment.type.skipStringRes,
|
stringRes = segment.segment.type.skipStringRes,
|
||||||
onClick = {
|
onClick = {
|
||||||
segmentCancelled = true
|
viewModel.updateSegment(segment.segment.id, false)
|
||||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
|
|
@ -57,7 +58,6 @@ import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
|
||||||
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 com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
|
|
@ -164,7 +164,7 @@ class PlaybackViewModel
|
||||||
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
||||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||||
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||||
|
|
||||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||||
|
|
@ -962,10 +962,10 @@ class PlaybackViewModel
|
||||||
/**
|
/**
|
||||||
* Cancels listening for segments and clears current segment state
|
* Cancels listening for segments and clears current segment state
|
||||||
*/
|
*/
|
||||||
private suspend fun resetSegmentState() {
|
private fun resetSegmentState() {
|
||||||
segmentJob?.cancel()
|
segmentJob?.cancel()
|
||||||
autoSkippedSegments.clear()
|
autoSkippedSegments.clear()
|
||||||
currentSegment.setValueOnMain(null)
|
currentSegment.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -988,15 +988,21 @@ class PlaybackViewModel
|
||||||
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
|
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
|
||||||
}
|
}
|
||||||
if (currentSegment != null &&
|
if (currentSegment != null &&
|
||||||
currentSegment.itemId == this@PlaybackViewModel.itemId &&
|
currentSegment.itemId == this@PlaybackViewModel.itemId
|
||||||
autoSkippedSegments.add(currentSegment.id)
|
|
||||||
) {
|
) {
|
||||||
Timber.d(
|
if (currentSegment.id !=
|
||||||
"Found media segment for %s: %s, %s",
|
this@PlaybackViewModel
|
||||||
currentSegment.itemId,
|
.currentSegment.value
|
||||||
currentSegment.id,
|
?.segment
|
||||||
currentSegment.type,
|
?.id
|
||||||
)
|
) {
|
||||||
|
Timber.d(
|
||||||
|
"Found media segment for %s: %s, %s",
|
||||||
|
currentSegment.itemId,
|
||||||
|
currentSegment.id,
|
||||||
|
currentSegment.type,
|
||||||
|
)
|
||||||
|
}
|
||||||
val playlist = this@PlaybackViewModel.playlist.value
|
val playlist = this@PlaybackViewModel.playlist.value
|
||||||
|
|
||||||
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
||||||
|
|
@ -1021,13 +1027,21 @@ class PlaybackViewModel
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
when (behavior) {
|
when (behavior) {
|
||||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||||
this@PlaybackViewModel.currentSegment.value = null
|
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||||
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
|
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||||
|
}
|
||||||
|
this@PlaybackViewModel.currentSegment.update {
|
||||||
|
MediaSegmentState(currentSegment, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||||
this@PlaybackViewModel.currentSegment.value =
|
this@PlaybackViewModel.currentSegment.update {
|
||||||
currentSegment
|
MediaSegmentState(
|
||||||
|
currentSegment,
|
||||||
|
autoSkippedSegments.contains(currentSegment.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -1046,6 +1060,28 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSegment(
|
||||||
|
segmentId: UUID?,
|
||||||
|
dismissed: Boolean,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val segment = currentSegment.value?.segment
|
||||||
|
if (segment != null && segment.id == segmentId) {
|
||||||
|
autoSkippedSegments.add(segment.id)
|
||||||
|
if (dismissed) {
|
||||||
|
currentSegment.update {
|
||||||
|
it?.copy(interacted = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentSegment.update {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun listenForTranscodeReason(): Job =
|
private fun listenForTranscodeReason(): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
currentPlayback.collectLatest {
|
currentPlayback.collectLatest {
|
||||||
|
|
@ -1379,3 +1415,8 @@ data class PlayerState(
|
||||||
val player: Player,
|
val player: Player,
|
||||||
val backend: PlayerBackend,
|
val backend: PlayerBackend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MediaSegmentState(
|
||||||
|
val segment: MediaSegmentDto,
|
||||||
|
val interacted: Boolean,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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="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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue