Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-11 22:31:34 -05:00
commit 290070fbfb
No known key found for this signature in database
9 changed files with 301 additions and 26 deletions

View file

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

View file

@ -949,6 +949,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,
@ -1172,6 +1180,7 @@ val advancedPreferences =
title = R.string.more,
preferences =
listOf(
AppPreference.QuickConnect,
AppPreference.SendAppLogs,
AppPreference.SendCrashReports,
AppPreference.DebugLogging,

View file

@ -391,6 +391,15 @@ fun CoroutineScope.launchIO(
block: suspend CoroutineScope.() -> Unit,
): 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).
*

View file

@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@ -163,8 +162,7 @@ fun PlaybackPageContent(
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
val currentSegment by viewModel.currentSegment.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@ -302,10 +300,10 @@ fun PlaybackPageContent(
}
val showSegment =
!segmentCancelled && currentSegment != null &&
currentSegment?.interacted == false &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
viewModel.updateSegment(currentSegment?.segment?.id, true)
}
Box(
@ -400,7 +398,7 @@ fun PlaybackPageContent(
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
@ -462,13 +460,12 @@ fun PlaybackPageContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = true
viewModel.updateSegment(segment.segment.id, true)
}
TextButton(
stringRes = segment.type.skipStringRes,
stringRes = segment.segment.type.skipStringRes,
onClick = {
segmentCancelled = true
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
viewModel.updateSegment(segment.segment.id, false)
},
modifier = Modifier.focusRequester(focusRequester),
)

View file

@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
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.nav.Destination
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.showToast
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.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
@ -164,7 +164,7 @@ class PlaybackViewModel
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
private val autoSkippedSegments = mutableSetOf<UUID>()
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
@ -962,10 +962,10 @@ class PlaybackViewModel
/**
* Cancels listening for segments and clears current segment state
*/
private suspend fun resetSegmentState() {
private fun resetSegmentState() {
segmentJob?.cancel()
autoSkippedSegments.clear()
currentSegment.setValueOnMain(null)
currentSegment.value = null
}
/**
@ -988,8 +988,13 @@ class PlaybackViewModel
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
}
if (currentSegment != null &&
currentSegment.itemId == this@PlaybackViewModel.itemId &&
autoSkippedSegments.add(currentSegment.id)
currentSegment.itemId == this@PlaybackViewModel.itemId
) {
if (currentSegment.id !=
this@PlaybackViewModel
.currentSegment.value
?.segment
?.id
) {
Timber.d(
"Found media segment for %s: %s, %s",
@ -997,6 +1002,7 @@ class PlaybackViewModel
currentSegment.id,
currentSegment.type,
)
}
val playlist = this@PlaybackViewModel.playlist.value
if (currentSegment.type == MediaSegmentType.OUTRO &&
@ -1021,13 +1027,21 @@ class PlaybackViewModel
withContext(Dispatchers.Main) {
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
this@PlaybackViewModel.currentSegment.value = null
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
if (autoSkippedSegments.add(currentSegment.id)) {
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
}
this@PlaybackViewModel.currentSegment.update {
MediaSegmentState(currentSegment, true)
}
}
SkipSegmentBehavior.ASK_TO_SKIP -> {
this@PlaybackViewModel.currentSegment.value =
currentSegment
this@PlaybackViewModel.currentSegment.update {
MediaSegmentState(
currentSegment,
autoSkippedSegments.contains(currentSegment.id),
)
}
}
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 =
viewModelScope.launchIO {
currentPlayback.collectLatest {
@ -1379,3 +1415,8 @@ data class PlayerState(
val player: Player,
val backend: PlayerBackend,
)
data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)

View file

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

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.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 {

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="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>