Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-03-11 14:22:47 -04:00
commit 31263a71f7
No known key found for this signature in database
35 changed files with 733 additions and 238 deletions

View file

@ -2,6 +2,7 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
@ -74,6 +75,7 @@ enum class SeerrAvailability(
/**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
*/
@Stable
@Serializable
data class DiscoverItem(
val id: Int,

View file

@ -25,7 +25,7 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
@ -57,6 +57,7 @@ class SeerrServerRepository
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/**
* Whether Seerr integration is currently active of not
@ -70,10 +71,11 @@ class SeerrServerRepository
}
fun error(
serverUrl: String,
server: SeerrServer,
user: SeerrUser,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
seerrApi.update("", null)
}
@ -174,8 +176,13 @@ class SeerrServerRepository
}
suspend fun removeServerForCurrentUser(): Boolean {
val current = current.firstOrNull() ?: return false
val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
val user =
when (val conn = connection.first()) {
SeerrConnectionStatus.NotConfigured -> return false
is SeerrConnectionStatus.Error -> conn.user
is SeerrConnectionStatus.Success -> conn.current.user
}
val rows = seerrServerDao.deleteUser(user)
clear()
return rows > 0
}
@ -190,7 +197,8 @@ sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val server: SeerrServer,
val user: SeerrUser,
val ex: Exception,
) : SeerrConnectionStatus
@ -316,7 +324,7 @@ class UserSwitchListener
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
seerrServerRepository.error(server, seerrUser, ex)
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import kotlinx.coroutines.flow.first
@ -125,4 +126,16 @@ class SeerrService
} else {
null
}
suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) {
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let { tvId ->
api.tvApi.tvTvIdGet(tvId = tvId)
}
} else {
null
}
}

View file

@ -171,7 +171,7 @@ object AppModule {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) {
appPreference.updateData {
preferences.appPreferences.updateInterfacePreferences {
it.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex)
}
}

View file

@ -771,6 +771,9 @@ fun CollectionFolderGrid(
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
onClickGoTo = {
onClickItem.invoke(position, it)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },

View file

@ -59,7 +59,7 @@ fun CollectionFolderPhotoAlbum(
index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
recursive = true,
recursive = recursive,
startSlideshow = false,
)
} else {

View file

@ -30,6 +30,7 @@ data class MoreDialogActions(
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
)
enum class ClearChosenStreams {
@ -278,7 +279,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(item.destination())
actions.onClickGoTo(item)
},
)
if (item.type in supportedPlayableTypes) {

View file

@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestSeasonDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
trailers = trailers,
requestOnClick = {
item.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = item.name ?: "",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
showRequestSeasonDialog = true
}
},
cancelOnClick = {
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
waitToLoad = params.fromLongClick,
)
}
if (showRequestSeasonDialog) {
RequestSeasonsDialog(
title = item?.name ?: "",
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k ->
item?.id?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false
},
onDismissRequest = { showRequestSeasonDialog = false },
)
}
}
private const val HEADER_ROW = 0
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
series: TvDetails,
rating: DiscoverRating?,
canCancel: Boolean,
seasons: List<Season>,
seasons: List<RequestSeason>,
similar: List<DiscoverItem>,
recommended: List<DiscoverItem>,
trailers: List<Trailer>,
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick,
goToOnClick = goToOnClick,

View file

@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<Season>>(listOf())
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>()
@ -103,6 +105,7 @@ class DiscoverSeriesViewModel
val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem)
updateSeasonStatus()
updateCanCancel()
withContext(Dispatchers.Main) {
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination)
}
private suspend fun updateSeasonStatus() {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
}
seasons.setValueOnMain(requestSeasons)
}
}
private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
@ -165,20 +205,43 @@ class DiscoverSeriesViewModel
fun request(
id: Int,
seasons: Set<Int>,
is4k: Boolean,
) {
viewModelScope.launchIO {
val request =
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
),
)
fetchAndSetItem().await()
updateCanCancel()
tvSeries.value?.let { tv ->
val currentRequest =
tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id ==
seerrServerRepository.currentUserId.first()
}
if (currentRequest != null) {
Timber.v("User has pending request, will update")
seerrService.api.requestApi.requestRequestIdPut(
requestId = currentRequest.id.toString(),
requestRequestIdPutRequest =
RequestRequestIdPutRequest(
is4k = is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
} else {
Timber.v("New request for %s seasons", seasons.size)
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
}
fetchAndSetItem().await()
updateSeasonStatus()
updateCanCancel()
}
}
}

View file

@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
pendingOnClick: () -> Unit = {},
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
// TODO?
pendingOnClick.invoke()
}
SeerrAvailability.PARTIALLY_AVAILABLE,
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
.onFocusChanged(buttonOnFocusChanged),
)
}
if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) {
item("request_partial") {
ExpandableFaButton(
title = R.string.request,
iconStringRes = R.string.fa_download,
onClick = {
requestOnClick.invoke()
},
enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
if (canCancel) {
item("cancel") {

View file

@ -0,0 +1,323 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PendingIndicator
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
data class RequestSeason(
val season: Season,
val availability: SeerrAvailability,
)
@Composable
fun RequestSeasons(
title: String,
seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit,
request4kEnabled: Boolean,
modifier: Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected =
remember {
mutableStateSetOf<Int>(
*seasons
.mapNotNull {
if (it.availability > SeerrAvailability.UNKNOWN) {
it.season.seasonNumber
} else {
null
}
}.toTypedArray(),
)
}
var is4k by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn {
item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selected.removeAll(allSeasonNumbers)
} else {
selected.addAll(allSeasonNumbers)
}
},
)
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
if (request4kEnabled) {
item {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected
SeasonListItem(
season = season,
selected = isSelected,
onClick = {
if (isSelected) {
selected.remove(seasonNumber)
} else {
seasonNumber?.let { selected.add(it) }
}
},
modifier = Modifier,
)
}
if (seasons.size > 7) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
}
}
}
}
@Composable
fun SeasonListItem(
season: RequestSeason,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
selected = false,
headlineContent = {
Text(
text =
season.season.name
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"),
)
},
supportingContent = {
season.season.episodeCount?.let {
Text(
// TODO should use plurals string
text = "${season.season.episodeCount} " + stringResource(R.string.episodes),
)
}
},
leadingContent = {
when (season.availability) {
SeerrAvailability.UNKNOWN -> {}
SeerrAvailability.DELETED -> {}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
PendingIndicator()
}
SeerrAvailability.PARTIALLY_AVAILABLE -> {
PartiallyAvailableIndicator()
}
SeerrAvailability.AVAILABLE -> {
AvailableIndicator()
}
}
},
trailingContent = {
Row {
Switch(
checked = selected,
onCheckedChange = {
onClick.invoke()
},
)
}
},
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun ClickSurface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Surface(
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
pressedContainerColor = MaterialTheme.colorScheme.inverseSurface,
pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
),
onClick = onClick,
content = content,
modifier = modifier,
)
}
@Composable
private fun ClickSwitch(
label: String,
checked: Boolean,
onClick: () -> Unit,
) {
ClickSurface(
onClick = onClick,
modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(horizontal = 8.dp)
.height(54.dp),
) {
Switch(
checked = checked,
onCheckedChange = {},
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = label,
)
}
}
}
@Composable
fun RequestSeasonsDialog(
title: String,
seasons: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestSeasons(
title = title,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
}
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
heightDp = 800,
)
@Composable
fun RequestSeasonsPreview() {
val seasons =
List(10) {
RequestSeason(
season =
Season(
seasonNumber = it + 1,
episodeCount = 10 + it,
),
availability =
if (it < 3) {
SeerrAvailability.AVAILABLE
} else {
SeerrAvailability.UNKNOWN
},
)
}
WholphinTheme {
RequestSeasons(
title = "Series title",
seasons = seasons,
request4kEnabled = true,
onSubmit = { _, _ -> },
modifier = Modifier.width(400.dp),
)
}
}

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -120,6 +123,7 @@ fun SeriesDetails(
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
val discoverSeries by viewModel.discoverSeries.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -230,6 +234,12 @@ fun SeriesDetails(
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
discoverSeries = discoverSeries,
onClickDiscoverSeries = {
discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
@ -350,6 +360,8 @@ fun SeriesDetailsContent(
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit,
discoverSeries: DiscoverItem?,
onClickDiscoverSeries: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -487,6 +499,25 @@ fun SeriesDetailsContent(
},
)
}
AnimatedVisibility(
visible = discoverSeries != null,
enter = fadeIn(),
exit = fadeOut(),
) {
ExpandableFaButton(
title = R.string.discover,
iconStringRes = R.string.fa_magnifying_glass_plus,
onClick = onClickDiscoverSeries,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
}
item {

View file

@ -58,6 +58,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -123,6 +124,7 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -232,6 +234,22 @@ class SeriesViewModel
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
viewModelScope.launchIO {
seerrService.active.collectLatest { active ->
val tv =
if (active) {
try {
seerrService.getTvSeries(item)?.let { DiscoverItem(it) }
} catch (ex: Exception) {
Timber.e(ex)
null
}
} else {
null
}
discoverSeries.update { tv }
}
}
}
mediaManagementService.deletedItemFlow
.onEach { deletedItem ->

View file

@ -213,7 +213,7 @@ fun DestinationContent(
CollectionFolderPhotoAlbum(
preferences = preferences,
itemId = destination.itemId,
recursive = true,
recursive = false,
modifier = modifier,
)
}
@ -419,9 +419,18 @@ fun CollectionFolder(
}
CollectionType.HOMEVIDEOS,
CollectionType.PHOTOS,
-> {
CollectionFolderPhotoAlbum(
preferences = preferences,
itemId = destination.itemId,
recursive = recursiveOverride ?: false,
modifier = modifier,
)
}
CollectionType.MUSICVIDEOS,
CollectionType.BOOKS,
CollectionType.PHOTOS,
-> {
CollectionFolderGeneric(
preferences,

View file

@ -400,7 +400,7 @@ fun PreferencesContent(
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(
conn.serverUrl,
conn.server.url,
conn.ex,
)
}

View file

@ -98,6 +98,7 @@ class SwitchSeerrViewModel
)
}
}
serverConnectionStatus.update { LoadingState.Success }
} else {
val message =
results

View file

@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -53,13 +52,16 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.transitionFactory
import coil3.size.Size
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
@ -70,12 +72,14 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "ImagePage"
private const val DEBUG = false
@SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalCoilApi::class)
@Composable
fun SlideshowPage(
slideshow: Destination.Slideshow,
@ -93,7 +97,7 @@ fun SlideshowPage(
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0)
val pager by viewModel.pager.observeAsState()
val imageState by viewModel.image.observeAsState()
// val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
val isZoomed = zoomFactor * 100 > 102
@ -197,9 +201,6 @@ fun SlideshowPage(
}
}
LaunchedEffect(imageState) {
reset(true)
}
val player = viewModel.player
val presentationState = rememberPresentationState(player)
LaunchedEffect(slideshowActive) {
@ -209,16 +210,6 @@ fun SlideshowPage(
var longPressing by remember { mutableStateOf(false) }
val contentModifier =
Modifier
.clickable(
interactionSource = null,
indication = null,
onClick = {
showOverlay = !showOverlay
},
)
Box(
modifier =
modifier
@ -303,149 +294,144 @@ fun SlideshowPage(
result
},
) {
when (loadingState) {
when (val st = loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null, modifier)
}
ImageLoadingState.Loading -> {
LoadingPage(modifier)
LoadingPage(modifier, false)
}
is ImageLoadingState.Success -> {
imageState?.let { imageState ->
if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) {
val mediaItem =
MediaItem
.Builder()
.setUri(imageState.url)
.build()
player.setMediaItem(mediaItem)
player.repeatMode =
if (slideshowState.enabled) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ONE
}
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
val imageState = st.image
LaunchedEffect(imageState) {
reset(true)
}
if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) {
val mediaItem =
MediaItem
.Builder()
.setUri(imageState.url)
.build()
player.setMediaItem(mediaItem)
player.repeatMode =
if (slideshowState.enabled) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ONE
}
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
}
val contentScale = ContentScale.Fit
val scaledModifier =
contentModifier.resizeWithContentScale(
contentScale,
presentationState.videoSizeDp,
)
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
}.rotate(rotateAnimation),
}
val contentScale = ContentScale.Fit
val scaledModifier =
Modifier.resizeWithContentScale(
contentScale,
presentationState.videoSizeDp,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
}
// If the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
modifier =
contentModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
val xTransform =
(screenWidth - panXAnimation) / (screenWidth * 2)
val yTransform =
(screenHeight - panYAnimation) / (screenHeight * 2)
if (DEBUG) {
Timber.d(
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.size(Size.ORIGINAL)
.crossfade(!showLoadingThumbnail)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
error = {
Text(
modifier =
Modifier
.align(Alignment.Center),
text = "Error loading image",
color = MaterialTheme.colorScheme.onBackground,
)
},
loading = {
ImageLoadingPlaceholder(
thumbnailUrl = imageState.thumbnailUrl,
showThumbnail = showLoadingThumbnail,
colorFilter = colorFilter,
modifier = Modifier.fillMaxSize(),
)
},
// Ensure that if an image takes a long time to load, it won't be skipped
onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE)
},
onSuccess = {
viewModel.pulseSlideshow()
},
onError = {
Timber.e(
it.result.throwable,
"Error loading image ${imageState.id}",
)
Toast
.makeText(
context,
"Error loading image: ${it.result.throwable.localizedMessage}",
Toast.LENGTH_LONG,
).show()
viewModel.pulseSlideshow()
},
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
}.rotate(rotateAnimation),
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
}
// If the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
val xTransform =
(screenWidth - panXAnimation) / (screenWidth * 2)
val yTransform =
(screenHeight - panYAnimation) / (screenHeight * 2)
if (DEBUG) {
Timber.d(
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.size(Size.ORIGINAL)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
error = {
Text(
modifier =
Modifier
.align(Alignment.Center),
text = "Error loading image",
color = MaterialTheme.colorScheme.onBackground,
)
},
// Ensure that if an image takes a long time to load, it won't be skipped
onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE)
},
onSuccess = {
viewModel.pulseSlideshow()
},
onError = {
Timber.e(
it.result.throwable,
"Error loading image ${imageState.id}",
)
Toast
.makeText(
context,
"Error loading image: ${it.result.throwable.localizedMessage}",
Toast.LENGTH_LONG,
).show()
viewModel.pulseSlideshow()
},
)
}
}
}
@ -455,30 +441,37 @@ fun SlideshowPage(
exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart),
) {
imageState?.let { imageState ->
ImageOverlay(
modifier =
contentModifier
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false },
player = player,
slideshowControls = slideshowControls,
slideshowEnabled = slideshowState.enabled,
image = imageState,
position = position,
count = pager?.size ?: -1,
onClickItem = {},
onLongClickItem = {},
onZoom = ::zoom,
onRotate = { rotation += it },
onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
when (val st = loadingState) {
ImageLoadingState.Error -> {}
ImageLoadingState.Loading -> {}
is ImageLoadingState.Success -> {
val imageState = st.image
ImageOverlay(
modifier =
Modifier
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false },
player = player,
slideshowControls = slideshowControls,
slideshowEnabled = slideshowState.enabled,
image = imageState,
position = position,
count = pager?.size ?: -1,
onClickItem = {},
onLongClickItem = {},
onZoom = ::zoom,
onRotate = { rotation += it },
onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
}
}
}
AnimatedVisibility(showFilterDialog) {

View file

@ -141,7 +141,7 @@ class SlideshowViewModel
parentId = slideshowSettings.parentId,
includeItemTypes = includeItemTypes,
fields = PhotoItemFields,
recursive = true,
recursive = slideshowSettings.recursive,
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
),
@ -193,7 +193,8 @@ class SlideshowViewModel
_pager.value?.let { pager ->
viewModelScope.launchIO {
try {
val image = pager.getBlocking(position)
val image =
if (position in pager.indices) pager.getBlocking(position) else null
Timber.v("Got image for $position: ${image != null}")
if (image != null) {
this@SlideshowViewModel.position.setValueOnMain(position)

View file

@ -92,12 +92,13 @@ object StreamFormatting {
context.getString(R.string.dolby_atmos)
}
profile?.contains("DTS:X", true) == true -> {
context.getString(R.string.dts_x)
}
profile?.contains("DTS:HD", true) == true -> {
context.getString(R.string.dts_hd)
profile?.contains("DTS", true) == true -> {
when {
profile.contains("X", true) -> context.getString(R.string.dts_x)
profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma)
profile.contains("HD", true) -> context.getString(R.string.dts_hd)
else -> context.getString(R.string.dts)
}
}
else -> {

View file

@ -401,6 +401,7 @@
<string name="clear_track_choices">Vyčistit výběr skladeb</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">True HD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -397,6 +397,7 @@
<string name="clear_track_choices">Ryd valg af spor</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -365,6 +365,7 @@
<string name="request_4k">In 4K anfragen</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -401,6 +401,7 @@
<string name="clear_track_choices">Opciones de pista claras</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -367,6 +367,7 @@
<string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -382,6 +382,7 @@
<string name="no_trailers">Pas de bandes-annonces</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -369,6 +369,7 @@
<string name="clear_track_choices">Hapus pilihan trek</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -383,6 +383,7 @@
<string name="only_forced_subtitles">Solo sottotitoli forzati</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -405,6 +405,7 @@
<string name="clear_track_choices">Limpar escolhas de faixas</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -382,6 +382,7 @@
<string name="clear_track_choices">Apagar seleções de faixas</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -394,6 +394,7 @@
<string name="clear_track_choices">Parça seçimlerini temizle</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -432,6 +432,7 @@
<string name="clear_track_choices">Чіткий вибір доріжки</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -351,6 +351,7 @@
<string name="only_forced_subtitles">仅强制字幕</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -349,6 +349,7 @@
<string name="no_trailers">無預告片</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>

View file

@ -451,6 +451,7 @@
<string name="clear_track_choices">Clear track choices</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="dts_hd_ma">DTS:HD MA</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
@ -744,6 +745,6 @@
<string name="play_as_type">Play as?</string>
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
<string name="continue_string">Continue</string>
<string name="select_all">Select all</string>
</resources>

View file

@ -1086,6 +1086,11 @@ components:
type: array
items:
$ref: '#/components/schemas/Episode'
status:
type: integer
example: 0
description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED
readOnly: true
TvDetails:
type: object
properties:
@ -1268,6 +1273,10 @@ components:
type: string
type:
type: string
seasons:
type: array
items:
$ref: '#/components/schemas/Season'
required:
- id
- status
@ -6135,16 +6144,10 @@ paths:
type: integer
example: 123
seasons:
# oneOf:
# - type: array
# items:
# type: integer
# minimum: 0
# - type: string
# enum: [all]
# TODO
type: string
enum: [all]
type: array
items:
type: integer
minimum: 0
nullable: true
is4k:
type: boolean