Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-03-16 13:12:09 -04:00
commit 9badecf086
No known key found for this signature in database
14 changed files with 115 additions and 31 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

@ -68,6 +68,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
@ -522,6 +523,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,
@ -556,6 +565,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)
@ -576,6 +586,7 @@ fun <T> BottomDialog(
} }
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
) )
} }
} }
@ -591,6 +602,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

@ -449,11 +449,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()

View file

@ -8,11 +8,11 @@ desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0" hiltWork = "1.3.0"
kache = "2.1.1" kache = "2.1.1"
kotlin = "2.3.10" kotlin = "2.3.20"
ksp = "2.3.6" ksp = "2.3.6"
coreKtx = "1.17.0" coreKtx = "1.18.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2026.02.01" composeBom = "2026.03.00"
mockk = "1.14.9" mockk = "1.14.9"
robolectric = "4.16.1" robolectric = "4.16.1"
multiplatformMarkdownRenderer = "0.39.2" multiplatformMarkdownRenderer = "0.39.2"
@ -20,10 +20,10 @@ okhttpBom = "5.3.2"
programguide = "1.6.0" programguide = "1.6.0"
slf4j2Timber = "1.2" slf4j2Timber = "1.2"
timber = "5.0.1" timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-beta01"
tvMaterial = "1.0.1" tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4" activityCompose = "1.13.0"
androidx-media3 = "1.9.2" androidx-media3 = "1.9.2"
coil = "3.4.0" coil = "3.4.0"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
@ -31,7 +31,7 @@ nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.10.0" lifecycleViewmodelNav3 = "2.10.0"
material3AdaptiveNav3 = "1.0.0-alpha03" material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.6" protobuf = "0.9.6"
datastore = "1.2.0" datastore = "1.2.1"
kotlinx-serialization = "1.10.0" kotlinx-serialization = "1.10.0"
protobuf-javalite = "4.34.0" protobuf-javalite = "4.34.0"
hilt = "2.59.2" hilt = "2.59.2"

Binary file not shown.

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

2
gradlew vendored
View file

@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.