Various fixes to screensaver, subtitle order, & playlist modifications (#1104)

## Description
Several small tweaks:

- Always put screensaver logo on left instead of random
- Disable playback speed option if audio is being passed through
- When opening playback settings such as speed or scale, focus on the
current value instead of top of the list; doesn't apply to subtitles so
it's easy to toggle them off
- If the user has a preferred subtitle language, show tracks in that
language first/top
- Add toast message after creating or adding to a playlist

### Related issues
Playback speed audio pass through: #164 

### Testing
Emulator & shield

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-15 19:31:18 -04:00 committed by GitHub
parent 23e5225c19
commit 9057dceef6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 107 additions and 26 deletions

View file

@ -135,7 +135,6 @@ fun AppScreensaverContent(
) )
var logoError by remember(currentItem) { mutableStateOf(false) } var logoError by remember(currentItem) { mutableStateOf(false) }
val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
if (!logoError) { if (!logoError) {
AsyncImage( AsyncImage(
model = model =
@ -150,7 +149,7 @@ fun AppScreensaverContent(
}, },
modifier = modifier =
Modifier Modifier
.align(alignment) .align(Alignment.BottomStart)
.size(width = 240.dp, height = 120.dp) .size(width = 240.dp, height = 120.dp)
.padding(16.dp), .padding(16.dp),
) )
@ -158,7 +157,7 @@ fun AppScreensaverContent(
Box( Box(
modifier = modifier =
Modifier Modifier
.align(alignment) .align(Alignment.BottomStart)
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(.5f) .fillMaxWidth(.5f)
.fillMaxHeight(.3f), .fillMaxHeight(.3f),
@ -169,7 +168,7 @@ fun AppScreensaverContent(
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(alignment), modifier = Modifier.align(Alignment.BottomStart),
) )
} }
} }

View file

@ -632,6 +632,7 @@ fun chooseStream(
streams: List<MediaStream>, streams: List<MediaStream>,
currentIndex: Int?, currentIndex: Int?,
type: MediaStreamType, type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
@ -669,7 +670,15 @@ fun chooseStream(
) )
} }
addAll( addAll(
streams.filter { it.type == type }.mapIndexed { index, stream -> streams
.filter { it.type == type }
.let {
if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) {
it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage }
} else {
it
}
}.mapIndexed { index, stream ->
val simpleStream = SimpleMediaStream.from(context, stream, true) val simpleStream = SimpleMediaStream.from(context, stream, true)
DialogItem( DialogItem(
selected = currentIndex == stream.index, selected = currentIndex == stream.index,
@ -677,7 +686,9 @@ fun chooseStream(
SelectedLeadingContent(currentIndex == stream.index) SelectedLeadingContent(currentIndex == stream.index)
}, },
headlineContent = { headlineContent = {
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) Text(
text = simpleStream.streamTitle ?: simpleStream.displayTitle,
)
}, },
supportingContent = { supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)

View file

@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
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 org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -43,7 +45,13 @@ class AddPlaylistViewModel
itemId: UUID, itemId: UUID,
) { ) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
try {
playlistCreator.addToServerPlaylist(playlistId, itemId) playlistCreator.addToServerPlaylist(playlistId, itemId)
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
} catch (ex: Exception) {
Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId)
showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT)
}
} }
} }
@ -55,6 +63,8 @@ class AddPlaylistViewModel
val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId)) val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId))
if (playlistId == null) { if (playlistId == null) {
showToast(context, "Error creating playlist", Toast.LENGTH_LONG) showToast(context, "Error creating playlist", Toast.LENGTH_LONG)
} else {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
} }
} }
} }

View file

@ -86,6 +86,13 @@ fun EpisodeDetails(
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -200,6 +207,7 @@ fun EpisodeDetails(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },

View file

@ -114,6 +114,13 @@ fun MovieDetails(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -246,6 +253,7 @@ fun MovieDetails(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },

View file

@ -138,6 +138,13 @@ fun SeriesOverview(
} }
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)
@ -252,6 +259,7 @@ fun SeriesOverview(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },

View file

@ -8,7 +8,6 @@ import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -37,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@ -64,6 +61,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes import com.github.damontecres.wholphin.ui.skipStringRes
@ -470,6 +468,14 @@ fun <T> BottomDialog(
gravity: Int, gravity: Int,
currentChoice: BottomDialogItem<T>? = null, currentChoice: BottomDialogItem<T>? = null,
) { ) {
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
if (currentChoice != null) {
LaunchedEffect(Unit) {
choices.indexOfFirstOrNull { it == currentChoice }?.let {
focusRequesters.getOrNull(it)?.tryRequestFocus()
}
}
}
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -504,6 +510,7 @@ fun <T> BottomDialog(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
ListItem( ListItem(
selected = choice == currentChoice, selected = choice == currentChoice,
enabled = choice.enabled,
onClick = { onClick = {
onDismissRequest() onDismissRequest()
onSelectChoice(index, choice) onSelectChoice(index, choice)
@ -524,6 +531,7 @@ fun <T> BottomDialog(
} }
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
) )
} }
} }
@ -539,6 +547,7 @@ data class BottomDialogItem<T>(
val data: T, val data: T,
val headline: String, val headline: String,
val supporting: String?, val supporting: String?,
val enabled: Boolean = true,
) )
@PreviewTvSpec @PreviewTvSpec

View file

@ -14,9 +14,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -32,6 +35,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
@ -54,6 +59,7 @@ data class PlaybackSettings(
val contentScale: ContentScale, val contentScale: ContentScale,
val subtitleDelay: Duration, val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean, val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: Boolean,
) )
@Composable @Composable
@ -137,6 +143,7 @@ fun PlaybackDialog(
data = PlaybackDialogType.PLAYBACK_SPEED, data = PlaybackDialogType.PLAYBACK_SPEED,
headline = stringResource(R.string.playback_speed), headline = stringResource(R.string.playback_speed),
supporting = settings.playbackSpeed.toString(), supporting = settings.playbackSpeed.toString(),
enabled = settings.playbackSpeedEnabled,
), ),
) )
if (enableVideoScale) { if (enableVideoScale) {
@ -395,6 +402,14 @@ fun StreamChoiceBottomDialog(
gravity: Int, gravity: Int,
currentChoice: Int? = null, currentChoice: Int? = null,
) { ) {
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
if (currentChoice != null) {
LaunchedEffect(Unit) {
choices.indexOfFirstOrNull { it.index == currentChoice }?.let {
focusRequesters.getOrNull(it)?.tryRequestFocus()
}
}
}
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -445,6 +460,7 @@ fun StreamChoiceBottomDialog(
if (choice.streamTitle != null) Text(choice.displayTitle) if (choice.streamTitle != null) Text(choice.displayTitle)
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
) )
} }
} }

View file

@ -596,6 +596,9 @@ fun PlaybackPageContent(
subtitleDelay = subtitleDelay, subtitleDelay = subtitleDelay,
hasSubtitleDownloadPermission = hasSubtitleDownloadPermission =
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
// TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = null playbackDialog = null

View file

@ -446,11 +446,20 @@ class PlaybackViewModel
// Create the correct player for the media // Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleLanguagePreference =
serverRepository.currentUserDto.value
?.configuration
?.subtitleLanguagePreference
val subtitleStreams = val subtitleStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
?.map { .let {
if (subtitleLanguagePreference.isNotNullOrBlank()) {
it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language }
} else {
it
}
}?.map {
SimpleMediaStream.from(context, it, true) SimpleMediaStream.from(context, it, true)
}.orEmpty() }.orEmpty()