mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/music
This commit is contained in:
commit
31263a71f7
35 changed files with 733 additions and 238 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -771,6 +771,9 @@ fun CollectionFolderGrid(
|
|||
onClickDelete = {
|
||||
showDeleteDialog = PositionItem(position, item)
|
||||
},
|
||||
onClickGoTo = {
|
||||
onClickItem.invoke(position, it)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ fun PreferencesContent(
|
|||
when (val conn = seerrConnection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
SeerrDialogMode.Error(
|
||||
conn.serverUrl,
|
||||
conn.server.url,
|
||||
conn.ex,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class SwitchSeerrViewModel
|
|||
)
|
||||
}
|
||||
}
|
||||
serverConnectionStatus.update { LoadingState.Success }
|
||||
} else {
|
||||
val message =
|
||||
results
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue