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

View file

@ -632,6 +632,7 @@ fun chooseStream(
streams: List<MediaStream>,
currentIndex: Int?,
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
): DialogParams =
DialogParams(
@ -669,7 +670,15 @@ fun chooseStream(
)
}
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)
DialogItem(
selected = currentIndex == stream.index,
@ -677,7 +686,9 @@ fun chooseStream(
SelectedLeadingContent(currentIndex == stream.index)
},
headlineContent = {
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
Text(
text = simpleStream.streamTitle ?: simpleStream.displayTitle,
)
},
supportingContent = {
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.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
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.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -43,7 +45,13 @@ class AddPlaylistViewModel
itemId: UUID,
) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
try {
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))
if (playlistId == null) {
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) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
@ -200,6 +207,7 @@ fun EpisodeDetails(
type,
)
},
preferredSubtitleLanguage = preferredSubtitleLanguage,
)
}
},

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@ -37,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.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.SelectedLeadingContent
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.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes
@ -470,6 +468,14 @@ fun <T> BottomDialog(
gravity: Int,
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
Dialog(
onDismissRequest = onDismissRequest,
@ -504,6 +510,7 @@ fun <T> BottomDialog(
val interactionSource = remember { MutableInteractionSource() }
ListItem(
selected = choice == currentChoice,
enabled = choice.enabled,
onClick = {
onDismissRequest()
onSelectChoice(index, choice)
@ -524,6 +531,7 @@ fun <T> BottomDialog(
}
},
interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
)
}
}
@ -539,6 +547,7 @@ data class BottomDialogItem<T>(
val data: T,
val headline: String,
val supporting: String?,
val enabled: Boolean = true,
)
@PreviewTvSpec

View file

@ -14,9 +14,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.platform.LocalView
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.ui.AppColors
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
enum class PlaybackDialogType {
@ -54,6 +59,7 @@ data class PlaybackSettings(
val contentScale: ContentScale,
val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: Boolean,
)
@Composable
@ -137,6 +143,7 @@ fun PlaybackDialog(
data = PlaybackDialogType.PLAYBACK_SPEED,
headline = stringResource(R.string.playback_speed),
supporting = settings.playbackSpeed.toString(),
enabled = settings.playbackSpeedEnabled,
),
)
if (enableVideoScale) {
@ -395,6 +402,14 @@ fun StreamChoiceBottomDialog(
gravity: Int,
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
Dialog(
onDismissRequest = onDismissRequest,
@ -445,6 +460,7 @@ fun StreamChoiceBottomDialog(
if (choice.streamTitle != null) Text(choice.displayTitle)
},
interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
)
}
}

View file

@ -596,6 +596,9 @@ fun PlaybackPageContent(
subtitleDelay = subtitleDelay,
hasSubtitleDownloadPermission =
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 = {
playbackDialog = null

View file

@ -446,11 +446,20 @@ class PlaybackViewModel
// Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleLanguagePreference =
serverRepository.currentUserDto.value
?.configuration
?.subtitleLanguagePreference
val subtitleStreams =
mediaSource.mediaStreams
?.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)
}.orEmpty()