mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Lots of changes for series
This commit is contained in:
parent
d7dde352bc
commit
62f557988e
34 changed files with 1956 additions and 216 deletions
|
|
@ -53,7 +53,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
DolphinTheme {
|
DolphinTheme(true) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = RectangleShape,
|
shape = RectangleShape,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.github.damontecres.dolphin.data.model
|
package com.github.damontecres.dolphin.data.model
|
||||||
|
|
||||||
|
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -19,7 +21,37 @@ data class BaseItem(
|
||||||
|
|
||||||
@Transient val name = data.name
|
@Transient val name = data.name
|
||||||
|
|
||||||
fun destination() = Destination.MediaItem(id, type, this)
|
fun destination(): Destination.MediaItem {
|
||||||
|
val result =
|
||||||
|
// Redirect episodes & seasons to their series if possible
|
||||||
|
when (type) {
|
||||||
|
BaseItemKind.EPISODE -> {
|
||||||
|
data.indexNumber?.let { episode ->
|
||||||
|
data.parentIndexNumber?.let { season ->
|
||||||
|
Destination.MediaItem(
|
||||||
|
data.seriesId!!,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
this,
|
||||||
|
SeasonEpisode(season, episode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: Destination.MediaItem(id, type, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseItemKind.SEASON ->
|
||||||
|
data.parentIndexNumber?.let { season ->
|
||||||
|
Destination.MediaItem(
|
||||||
|
data.seriesId!!,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
this,
|
||||||
|
SeasonEpisode(season, 0),
|
||||||
|
)
|
||||||
|
} ?: Destination.MediaItem(id, type, this)
|
||||||
|
|
||||||
|
else -> Destination.MediaItem(id, type, this)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(
|
fun from(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ fun CharSequence?.isNotNullOrBlank(): Boolean {
|
||||||
return !this.isNullOrBlank()
|
return !this.isNullOrBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
|
||||||
|
val index = this.indexOfFirst(predicate)
|
||||||
|
return if (index >= 0) index else null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
||||||
*/
|
*/
|
||||||
|
|
@ -61,6 +66,12 @@ fun Modifier.ifElse(
|
||||||
ifFalseModifier: Modifier = Modifier,
|
ifFalseModifier: Modifier = Modifier,
|
||||||
): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier)
|
): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier)
|
||||||
|
|
||||||
|
fun Modifier.ifElse(
|
||||||
|
condition: Boolean,
|
||||||
|
ifTrueModifier: () -> Modifier,
|
||||||
|
ifFalseModifier: () -> Modifier = { Modifier },
|
||||||
|
): Modifier = then(if (condition) ifTrueModifier.invoke() else ifFalseModifier.invoke())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
|
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
|
||||||
* accidentally move to another element.
|
* accidentally move to another element.
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ fun ItemCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
NullCard(modifier, cardWidth, cardHeight)
|
NullCard(modifier, cardWidth, cardHeight, interactionSource)
|
||||||
} else {
|
} else {
|
||||||
val dto = item.data
|
val dto = item.data
|
||||||
// TODO better aspect ratio handling
|
// TODO better aspect ratio handling
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.dolphin.ui.cards
|
package com.github.damontecres.dolphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -14,10 +15,12 @@ fun NullCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
cardWidth: Dp = 150.dp,
|
cardWidth: Dp = 150.dp,
|
||||||
cardHeight: Dp = 200.dp,
|
cardHeight: Dp = 200.dp,
|
||||||
|
interactionSource: MutableInteractionSource? = null,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
interactionSource = interactionSource,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.size(cardWidth, cardHeight),
|
modifier = Modifier.size(cardWidth, cardHeight),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.components
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.ui.FontAwesome
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlayButton(
|
||||||
|
@StringRes title: Int,
|
||||||
|
resume: Duration,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: (position: Duration) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onClick.invoke(resume) },
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandablePlayButton(
|
||||||
|
@StringRes title: Int,
|
||||||
|
resume: Duration,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: (position: Duration) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
Button(
|
||||||
|
onClick = { onClick.invoke(resume) },
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(isFocused) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandableFaButton(
|
||||||
|
@StringRes title: Int,
|
||||||
|
@StringRes iconStringRes: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.heightIn(min = 44.dp),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(iconStringRes),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(isFocused) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusState
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.ButtonDefaults
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.R
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlayButtons(
|
||||||
|
resumePosition: Duration,
|
||||||
|
playOnClick: (position: Duration) -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(firstFocus),
|
||||||
|
) {
|
||||||
|
if (resumePosition > Duration.ZERO) {
|
||||||
|
item {
|
||||||
|
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||||
|
PlayButton(
|
||||||
|
R.string.resume,
|
||||||
|
resumePosition,
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
playOnClick,
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged)
|
||||||
|
.focusRequester(firstFocus)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
PlayButton(
|
||||||
|
R.string.restart,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
playOnClick,
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||||
|
PlayButton(
|
||||||
|
R.string.play,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
playOnClick,
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged)
|
||||||
|
.focusRequester(firstFocus)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// More button
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = moreOnClick,
|
||||||
|
onLongClick = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged(buttonOnFocusChanged),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.more),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,543 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.components.details
|
||||||
|
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Tab
|
||||||
|
import androidx.tv.material3.TabRow
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.dolphin.R
|
||||||
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||||
|
import com.github.damontecres.dolphin.ui.cards.ItemCard
|
||||||
|
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
|
||||||
|
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
|
||||||
|
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||||
|
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||||
|
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
||||||
|
import com.github.damontecres.dolphin.ui.ifElse
|
||||||
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import com.github.damontecres.dolphin.ui.playOnClickSound
|
||||||
|
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
||||||
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SeasonEpisode(
|
||||||
|
val season: Int,
|
||||||
|
val episode: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeriesDetailParent(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SeriesViewModel = hiltViewModel(),
|
||||||
|
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0),
|
||||||
|
) {
|
||||||
|
val firstItemFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
OneTimeLaunchedEffect {
|
||||||
|
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
||||||
|
viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season)
|
||||||
|
}
|
||||||
|
val series by viewModel.item.observeAsState(null)
|
||||||
|
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||||
|
val episodes by viewModel.episodes.observeAsState(listOf())
|
||||||
|
var seasonEpisode by rememberSaveable(
|
||||||
|
destination,
|
||||||
|
stateSaver =
|
||||||
|
Saver(
|
||||||
|
save = { listOf(it.season, it.episode) },
|
||||||
|
restore = { SeasonEpisode(it[0], it[1]) },
|
||||||
|
),
|
||||||
|
) { mutableStateOf(initialSeasonEpisode) }
|
||||||
|
|
||||||
|
LaunchedEffect(episodes) {
|
||||||
|
if (episodes.isNotEmpty()) {
|
||||||
|
// TODO focus on first episode when changing seasons
|
||||||
|
// firstItemFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series == null) {
|
||||||
|
// TODO
|
||||||
|
Text(text = "Loading...")
|
||||||
|
} else {
|
||||||
|
series?.let { series ->
|
||||||
|
SeriesDetailPage(
|
||||||
|
series = series,
|
||||||
|
seasons = seasons,
|
||||||
|
episodes = episodes,
|
||||||
|
seasonEpisode = seasonEpisode,
|
||||||
|
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
|
||||||
|
firstItemFocusRequester = firstItemFocusRequester,
|
||||||
|
onFocus = {
|
||||||
|
if (it.season != seasonEpisode.season) {
|
||||||
|
viewModel.loadEpisodes(seasons[it.season]!!.id)
|
||||||
|
}
|
||||||
|
seasonEpisode = it
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val resumePosition =
|
||||||
|
it.data.userData
|
||||||
|
?.playbackPositionTicks
|
||||||
|
?.ticks ?: Duration.ZERO
|
||||||
|
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it))
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SeriesDetailPage(
|
||||||
|
series: BaseItem,
|
||||||
|
seasons: List<BaseItem?>,
|
||||||
|
episodes: List<BaseItem?>,
|
||||||
|
seasonEpisode: SeasonEpisode,
|
||||||
|
backdropImageUrl: String?,
|
||||||
|
firstItemFocusRequester: FocusRequester,
|
||||||
|
onFocus: (SeasonEpisode) -> Unit,
|
||||||
|
onClick: (BaseItem) -> Unit,
|
||||||
|
onLongClick: (BaseItem) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
// TODO need to map between season index and tab index in case of missing seasons
|
||||||
|
var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) }
|
||||||
|
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
|
||||||
|
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||||
|
val tabRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val focusedEpisode = episodes.getOrNull(seasonEpisode.episode)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .fillMaxHeight(.33f)
|
||||||
|
.height(460.dp)
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
) {
|
||||||
|
if (backdropImageUrl.isNotNullOrBlank()) {
|
||||||
|
val gradientColor = MaterialTheme.colorScheme.background
|
||||||
|
AsyncImage(
|
||||||
|
model = backdropImageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
alignment = Alignment.TopEnd,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight(.5f)
|
||||||
|
.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(Color.Transparent, gradientColor),
|
||||||
|
startY = 500f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colors = listOf(gradientColor, Color.Transparent),
|
||||||
|
endX = 400f,
|
||||||
|
startX = 100f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.ifElse(focusRequesters.size > selectedTabIndex, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) })
|
||||||
|
.focusRequester(tabRowFocusRequester)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
seasons.forEachIndexed { index, season ->
|
||||||
|
season?.let { season ->
|
||||||
|
Tab(
|
||||||
|
selected = index == selectedTabIndex,
|
||||||
|
onFocus = {},
|
||||||
|
onClick = {
|
||||||
|
selectedTabIndex = index
|
||||||
|
onFocus.invoke(SeasonEpisode(index, 0))
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequesters[index]),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = season.name ?: "Season ${season.data.indexNumber}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
series.name?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
// Episode header
|
||||||
|
focusedEpisode?.let { ep ->
|
||||||
|
FocusedEpisodeHeader(
|
||||||
|
ep = ep,
|
||||||
|
overviewOnClick = {
|
||||||
|
// TODO show full overview dialog
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
key(seasonEpisode.season) {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
LazyRow(
|
||||||
|
state = state,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 32.dp),
|
||||||
|
modifier = modifier.focusRestorer(firstItemFocusRequester),
|
||||||
|
) {
|
||||||
|
itemsIndexed(episodes) { index, episode ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
if (interactionSource.collectIsFocusedAsState().value) {
|
||||||
|
onFocus.invoke(SeasonEpisode(selectedTabIndex, index))
|
||||||
|
}
|
||||||
|
ItemCard(
|
||||||
|
item = episode,
|
||||||
|
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||||
|
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(firstItemFocusRequester),
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
focusedEpisode?.let { ep ->
|
||||||
|
FocusedEpisodeFooter(
|
||||||
|
ep = ep,
|
||||||
|
playOnClick = {},
|
||||||
|
moreOnClick = {},
|
||||||
|
watchOnClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FocusedEpisodeHeader(
|
||||||
|
ep: BaseItem,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val dto = ep.data
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dto.episodeTitle ?: dto.name ?: "",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
val details =
|
||||||
|
buildList {
|
||||||
|
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||||
|
add("S${dto.parentIndexNumber} E${dto.indexNumber}")
|
||||||
|
}
|
||||||
|
dto.premiereDate?.let { add(formatDateTime(it)) }
|
||||||
|
dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let {
|
||||||
|
add(it)
|
||||||
|
}
|
||||||
|
dto.seriesStudio?.let { add(it) }
|
||||||
|
}
|
||||||
|
DotSeparatedRow(
|
||||||
|
texts = details,
|
||||||
|
textStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Critic: ${dto.criticRating}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
StarRating(
|
||||||
|
rating100 =
|
||||||
|
dto
|
||||||
|
.userData
|
||||||
|
?.rating
|
||||||
|
?.times(100)
|
||||||
|
?.toInt() ?: 0,
|
||||||
|
onRatingChange = {},
|
||||||
|
enabled = false,
|
||||||
|
precision = StarRatingPrecision.HALF,
|
||||||
|
playSoundOnFocus = true,
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dto.overview?.let { overview ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
val bgColor =
|
||||||
|
if (isFocused) {
|
||||||
|
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f)
|
||||||
|
} else {
|
||||||
|
Color.Unspecified
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||||
|
.playSoundOnFocus(true)
|
||||||
|
.clickable(
|
||||||
|
enabled = true,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = LocalIndication.current,
|
||||||
|
) {
|
||||||
|
playOnClickSound(context)
|
||||||
|
overviewOnClick.invoke()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = overview,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FocusedEpisodeFooter(
|
||||||
|
ep: BaseItem,
|
||||||
|
playOnClick: (Duration) -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
watchOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dto = ep.data
|
||||||
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusGroup()
|
||||||
|
.focusProperties {
|
||||||
|
onEnter = {
|
||||||
|
firstFocus.tryRequestFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (resumePosition > Duration.ZERO) {
|
||||||
|
item {
|
||||||
|
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||||
|
ExpandablePlayButton(
|
||||||
|
R.string.resume,
|
||||||
|
resumePosition,
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
playOnClick,
|
||||||
|
Modifier.focusRequester(firstFocus),
|
||||||
|
// .onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
R.string.restart,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
playOnClick,
|
||||||
|
Modifier,
|
||||||
|
// .onFocusChanged(buttonOnFocusChanged),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
R.string.play,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
playOnClick,
|
||||||
|
Modifier.focusRequester(firstFocus),
|
||||||
|
// .onFocusChanged(buttonOnFocusChanged)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val played = dto.userData?.played ?: false
|
||||||
|
// Played button
|
||||||
|
item {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = if (played) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash,
|
||||||
|
onClick = watchOnClick,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// More button
|
||||||
|
item {
|
||||||
|
ExpandablePlayButton(
|
||||||
|
R.string.more,
|
||||||
|
Duration.ZERO,
|
||||||
|
Icons.Default.MoreVert,
|
||||||
|
{ moreOnClick.invoke() },
|
||||||
|
Modifier,
|
||||||
|
// .onFocusChanged(buttonOnFocusChanged)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
dto.mediaStreams
|
||||||
|
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
|
?.let { stream ->
|
||||||
|
stream.displayTitle?.let {
|
||||||
|
TitleValueText(
|
||||||
|
"Video",
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.mediaStreams
|
||||||
|
?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||||
|
?.let { stream ->
|
||||||
|
stream.displayTitle?.let {
|
||||||
|
TitleValueText(
|
||||||
|
"Audio",
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.mediaStreams
|
||||||
|
?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() }
|
||||||
|
?.mapNotNull { it.language }
|
||||||
|
?.joinToString(", ")
|
||||||
|
?.let {
|
||||||
|
if (it.isNotNullOrBlank()) {
|
||||||
|
TitleValueText(
|
||||||
|
"Subtitles",
|
||||||
|
it,
|
||||||
|
modifier = Modifier.widthIn(max = 64.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.components.details
|
||||||
|
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.dolphin.ui.components.PlayButtons
|
||||||
|
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||||
|
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||||
|
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
||||||
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
|
import com.github.damontecres.dolphin.ui.playOnClickSound
|
||||||
|
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.SortedMap
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoDetailsHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
description: String?,
|
||||||
|
details: List<String>,
|
||||||
|
moreDetails: SortedMap<String, String>,
|
||||||
|
rating: Float?,
|
||||||
|
resumeTime: Duration?,
|
||||||
|
watched: Boolean,
|
||||||
|
favorite: Boolean,
|
||||||
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
descriptionOnClick: () -> Unit,
|
||||||
|
moreOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.displayMedium.copy(
|
||||||
|
shadow =
|
||||||
|
Shadow(
|
||||||
|
color = Color.DarkGray,
|
||||||
|
offset = Offset(5f, 2f),
|
||||||
|
blurRadius = 2f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// Subtitle
|
||||||
|
if (subtitle.isNotNullOrBlank()) {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.displaySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.alpha(0.75f),
|
||||||
|
) {
|
||||||
|
// Rating
|
||||||
|
if (rating != null) {
|
||||||
|
StarRating(
|
||||||
|
rating100 = (rating * 100).toInt(),
|
||||||
|
precision = StarRatingPrecision.HALF,
|
||||||
|
onRatingChange = {
|
||||||
|
// TODO
|
||||||
|
},
|
||||||
|
enabled = false,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
playSoundOnFocus = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick info
|
||||||
|
if (details.isNotEmpty()) {
|
||||||
|
DotSeparatedRow(
|
||||||
|
modifier = Modifier.padding(top = 6.dp, start = 8.dp),
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
texts = details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Description
|
||||||
|
if (description.isNotNullOrBlank()) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
val bgColor =
|
||||||
|
if (isFocused) {
|
||||||
|
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f)
|
||||||
|
} else {
|
||||||
|
Color.Unspecified
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
}
|
||||||
|
}.playSoundOnFocus(true)
|
||||||
|
.clickable(
|
||||||
|
enabled = true,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = LocalIndication.current,
|
||||||
|
) {
|
||||||
|
playOnClickSound(context)
|
||||||
|
descriptionOnClick.invoke()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Key-Values
|
||||||
|
if (moreDetails.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(top = 8.dp, start = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
moreDetails.forEach { (key, value) ->
|
||||||
|
TitleValueText(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayButtons(
|
||||||
|
resumePosition = resumeTime ?: Duration.ZERO,
|
||||||
|
playOnClick = { position ->
|
||||||
|
// TODO
|
||||||
|
},
|
||||||
|
moreOnClick = { moreOnClick.invoke() },
|
||||||
|
buttonOnFocusChanged = {
|
||||||
|
scope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
},
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,23 @@ class CollectionFolderViewModel
|
||||||
private suspend fun setup() {
|
private suspend fun setup() {
|
||||||
if (!pager.isInitialized) {
|
if (!pager.isInitialized) {
|
||||||
item.value?.let { item ->
|
item.value?.let { item ->
|
||||||
|
val includeItemTypes =
|
||||||
|
when (item.data.collectionType) {
|
||||||
|
CollectionType.UNKNOWN -> TODO()
|
||||||
|
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
||||||
|
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
||||||
|
CollectionType.MUSIC -> TODO()
|
||||||
|
CollectionType.MUSICVIDEOS -> TODO()
|
||||||
|
CollectionType.TRAILERS -> TODO()
|
||||||
|
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
||||||
|
CollectionType.BOXSETS -> TODO()
|
||||||
|
CollectionType.BOOKS -> TODO()
|
||||||
|
CollectionType.PHOTOS -> TODO()
|
||||||
|
CollectionType.LIVETV -> TODO()
|
||||||
|
CollectionType.PLAYLISTS -> TODO()
|
||||||
|
CollectionType.FOLDERS -> TODO()
|
||||||
|
null -> TODO()
|
||||||
|
}
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
|
|
@ -58,7 +75,7 @@ class CollectionFolderViewModel
|
||||||
mediaTypes = null,
|
mediaTypes = null,
|
||||||
// recursive = true,
|
// recursive = true,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
includeItemTypes = includeItemTypes,
|
||||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
|
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
|
||||||
|
|
@ -91,7 +108,17 @@ fun CollectionFolderDetails(
|
||||||
pager?.let { pager ->
|
pager?.let { pager ->
|
||||||
when (library!!.collectionType) {
|
when (library!!.collectionType) {
|
||||||
CollectionType.UNKNOWN -> TODO()
|
CollectionType.UNKNOWN -> TODO()
|
||||||
CollectionType.MOVIES -> TODO()
|
|
||||||
|
// TODO?
|
||||||
|
CollectionType.MOVIES ->
|
||||||
|
TVShowCollectionDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
library!!,
|
||||||
|
item!!,
|
||||||
|
pager,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
CollectionType.TVSHOWS -> {
|
CollectionType.TVSHOWS -> {
|
||||||
TVShowCollectionDetails(
|
TVShowCollectionDetails(
|
||||||
preferences,
|
preferences,
|
||||||
|
|
@ -106,7 +133,17 @@ fun CollectionFolderDetails(
|
||||||
CollectionType.MUSIC -> TODO()
|
CollectionType.MUSIC -> TODO()
|
||||||
CollectionType.MUSICVIDEOS -> TODO()
|
CollectionType.MUSICVIDEOS -> TODO()
|
||||||
CollectionType.TRAILERS -> TODO()
|
CollectionType.TRAILERS -> TODO()
|
||||||
CollectionType.HOMEVIDEOS -> TODO()
|
|
||||||
|
// TODO?
|
||||||
|
CollectionType.HOMEVIDEOS ->
|
||||||
|
TVShowCollectionDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
library!!,
|
||||||
|
item!!,
|
||||||
|
pager,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
CollectionType.BOXSETS -> TODO()
|
CollectionType.BOXSETS -> TODO()
|
||||||
CollectionType.BOOKS -> TODO()
|
CollectionType.BOOKS -> TODO()
|
||||||
CollectionType.PHOTOS -> TODO()
|
CollectionType.PHOTOS -> TODO()
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,43 @@
|
||||||
package com.github.damontecres.dolphin.ui.detail
|
package com.github.damontecres.dolphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.dolphin.ui.components.details.VideoDetailsHeader
|
||||||
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -45,56 +64,99 @@ fun EpisodeDetails(
|
||||||
Text(text = "Loading...")
|
Text(text = "Loading...")
|
||||||
} else {
|
} else {
|
||||||
item?.let { item ->
|
item?.let { item ->
|
||||||
val dto = item.data
|
EpisodeDetailsContent(
|
||||||
LazyColumn(
|
item = item,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
backdropImageUrl =
|
||||||
contentPadding = PaddingValues(32.dp),
|
remember {
|
||||||
modifier = modifier,
|
item.data.parentBackdropItemId?.let {
|
||||||
) {
|
viewModel.imageUrl(it, ImageType.BACKDROP)
|
||||||
item {
|
|
||||||
Text(text = item.name ?: "Unknown")
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
|
||||||
Text(
|
|
||||||
text = "S${dto.parentIndexNumber} E${dto.indexNumber}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dto.overview?.let {
|
|
||||||
item {
|
|
||||||
Text(text = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dto.userData?.playbackPositionTicks?.ticks?.let {
|
|
||||||
if (it > 60.seconds) {
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
navigationManager.navigateTo(
|
|
||||||
Destination.Playback(
|
|
||||||
item.id,
|
|
||||||
it.inWholeMilliseconds,
|
|
||||||
item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(text = "Resume")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
modifier = modifier,
|
||||||
item {
|
)
|
||||||
Button(
|
}
|
||||||
onClick = {
|
}
|
||||||
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
|
}
|
||||||
},
|
|
||||||
) {
|
@Composable
|
||||||
Text(text = "Play")
|
fun EpisodeDetailsContent(
|
||||||
}
|
item: BaseItem,
|
||||||
}
|
backdropImageUrl: String?,
|
||||||
}
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
|
||||||
|
val dto = item.data
|
||||||
|
val title = item.name ?: "Unknown"
|
||||||
|
val subtitle = dto.seriesName
|
||||||
|
val description = dto.overview
|
||||||
|
|
||||||
|
val details =
|
||||||
|
buildList {
|
||||||
|
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||||
|
add("S${dto.parentIndexNumber} E${dto.indexNumber}")
|
||||||
|
}
|
||||||
|
dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let {
|
||||||
|
add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .fillMaxHeight(.33f)
|
||||||
|
.height(460.dp)
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
|
) {
|
||||||
|
if (backdropImageUrl.isNotNullOrBlank()) {
|
||||||
|
Timber.v("Banner image url: $backdropImageUrl")
|
||||||
|
val gradientColor = MaterialTheme.colorScheme.background
|
||||||
|
AsyncImage(
|
||||||
|
model = backdropImageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
alignment = Alignment.TopEnd,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(Color.Transparent, gradientColor),
|
||||||
|
startY = 500f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colors = listOf(gradientColor, Color.Transparent),
|
||||||
|
endX = 400f,
|
||||||
|
startX = 100f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.fillMaxWidth(0.8f)) {
|
||||||
|
Spacer(modifier = Modifier.height(60.dp))
|
||||||
|
VideoDetailsHeader(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
description = description,
|
||||||
|
details = details,
|
||||||
|
moreDetails = sortedMapOf(),
|
||||||
|
rating = 0f,
|
||||||
|
resumeTime = dto.userData?.playbackPositionTicks?.ticks ?: 0.seconds,
|
||||||
|
watched = dto.userData?.played ?: false,
|
||||||
|
favorite = dto.userData?.isFavorite ?: false,
|
||||||
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
|
descriptionOnClick = {},
|
||||||
|
moreOnClick = {},
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import com.github.damontecres.dolphin.data.model.convertModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -42,4 +44,9 @@ abstract class ItemViewModel<T : DolphinModel>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun imageUrl(
|
||||||
|
itemId: UUID,
|
||||||
|
type: ImageType,
|
||||||
|
): String? = api.imageApi.getItemImageUrl(itemId, type)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
package com.github.damontecres.dolphin.ui.detail
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MediaItemContent(
|
|
||||||
preferences: UserPreferences,
|
|
||||||
navigationManager: NavigationManager,
|
|
||||||
destination: Destination.MediaItem,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
when (destination.type) {
|
|
||||||
BaseItemKind.SERIES -> SeriesDetails(preferences, navigationManager, destination, modifier)
|
|
||||||
BaseItemKind.SEASON -> SeasonDetails(preferences, navigationManager, destination, modifier)
|
|
||||||
BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
|
|
||||||
BaseItemKind.MOVIE -> TODO()
|
|
||||||
BaseItemKind.VIDEO -> TODO()
|
|
||||||
BaseItemKind.COLLECTION_FOLDER -> {
|
|
||||||
CollectionFolderDetails(
|
|
||||||
preferences,
|
|
||||||
navigationManager,
|
|
||||||
destination,
|
|
||||||
modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Text("Unsupported item type: ${destination.type}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MovieViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
api: ApiClient,
|
||||||
|
) : ItemViewModel<Video>(api)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MovieDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MovieViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.init(destination.itemId, destination.item)
|
||||||
|
}
|
||||||
|
val item by viewModel.item.observeAsState()
|
||||||
|
if (item == null) {
|
||||||
|
Text(text = "Loading...")
|
||||||
|
} else {
|
||||||
|
item?.let { item ->
|
||||||
|
val dto = item.data
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(32.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(text = item.name ?: "Unknown")
|
||||||
|
}
|
||||||
|
dto.overview?.let {
|
||||||
|
item {
|
||||||
|
Text(text = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dto.userData?.playbackPositionTicks?.ticks?.let {
|
||||||
|
if (it > 60.seconds) {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
navigationManager.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
it.inWholeMilliseconds,
|
||||||
|
item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = "Resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = "Play")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package com.github.damontecres.dolphin.ui.detail
|
package com.github.damontecres.dolphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
|
@ -79,16 +83,22 @@ fun SeasonDetails(
|
||||||
Text(text = "Loading...")
|
Text(text = "Loading...")
|
||||||
} else {
|
} else {
|
||||||
item?.let { item ->
|
item?.let { item ->
|
||||||
|
var focusedChild by remember { mutableIntStateOf(0) }
|
||||||
LazyColumn(modifier = modifier) {
|
LazyColumn(modifier = modifier) {
|
||||||
item {
|
item {
|
||||||
Text(text = item.name ?: "Unknown")
|
Text(text = item.name ?: "Unknown")
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = "Episodes",
|
title = "Episodes",
|
||||||
items = episodes,
|
items = episodes,
|
||||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||||
onLongClickItem = { },
|
onLongClickItem = { },
|
||||||
|
cardOnFocus = { isFocused, index ->
|
||||||
|
if (isFocused) focusedChild = index
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -96,3 +106,12 @@ fun SeasonDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EpisodeHeader(
|
||||||
|
item: BaseItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ class SeriesViewModel
|
||||||
override fun init(
|
override fun init(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
): Job? =
|
): Job =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
super.init(itemId, potential)?.join()
|
super.init(itemId, potential)?.join()
|
||||||
item.value?.let { item ->
|
item.value?.let { item ->
|
||||||
|
|
@ -63,8 +64,53 @@ class SeriesViewModel
|
||||||
ItemPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
ItemPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||||
pager.init()
|
pager.init()
|
||||||
seasons.value = pager
|
seasons.value = pager
|
||||||
|
Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun init(
|
||||||
|
itemId: UUID,
|
||||||
|
potential: BaseItem?,
|
||||||
|
season: Int?,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
init(itemId, potential).join()
|
||||||
|
season?.let {
|
||||||
|
(seasons.value!! as ItemPager)
|
||||||
|
.getBlocking(season)
|
||||||
|
?.let {
|
||||||
|
loadEpisodes(it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
|
||||||
|
fun loadEpisodes(seasonId: UUID): Job {
|
||||||
|
Timber.v("Loading episodes for season $seasonId")
|
||||||
|
episodes.value = listOf()
|
||||||
|
return viewModelScope.launch {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = seasonId,
|
||||||
|
recursive = false,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
|
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
||||||
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
|
fields =
|
||||||
|
listOf(
|
||||||
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
|
ItemFields.CHILD_COUNT,
|
||||||
|
ItemFields.MEDIA_STREAMS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val pager = ItemPager(api, request, viewModelScope)
|
||||||
|
pager.init()
|
||||||
|
Timber.v("Loaded ${pager.size} episodes for season $seasonId")
|
||||||
|
episodes.value = pager
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.tv.material3.Button
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.data.model.Video
|
||||||
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class VideoViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
api: ApiClient,
|
||||||
|
) : ItemViewModel<Video>(api)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoDetails(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
navigationManager: NavigationManager,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MovieViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.init(destination.itemId, destination.item)
|
||||||
|
}
|
||||||
|
val item by viewModel.item.observeAsState()
|
||||||
|
if (item == null) {
|
||||||
|
Text(text = "Loading...")
|
||||||
|
} else {
|
||||||
|
item?.let { item ->
|
||||||
|
val dto = item.data
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(32.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(text = item.name ?: "Unknown")
|
||||||
|
}
|
||||||
|
dto.overview?.let {
|
||||||
|
item {
|
||||||
|
Text(text = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dto.userData?.playbackPositionTicks?.ticks?.let {
|
||||||
|
if (it > 60.seconds) {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
navigationManager.navigateTo(
|
||||||
|
Destination.Playback(
|
||||||
|
item.id,
|
||||||
|
it.inWholeMilliseconds,
|
||||||
|
item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = "Resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = "Play")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,6 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -171,12 +170,7 @@ fun MainPage(
|
||||||
title = stringResource(row.section.nameRes),
|
title = stringResource(row.section.nameRes),
|
||||||
items = row.items,
|
items = row.items,
|
||||||
onClickItem = {
|
onClickItem = {
|
||||||
navigationManager.navigateTo(
|
navigationManager.navigateTo(it.destination())
|
||||||
Destination.MediaItem(
|
|
||||||
it.id,
|
|
||||||
it.type,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onLongClickItem = {},
|
onLongClickItem = {},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
@file:UseSerializers(UuidSerializer::class)
|
||||||
|
|
||||||
package com.github.damontecres.dolphin.ui.nav
|
package com.github.damontecres.dolphin.ui.nav
|
||||||
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||||
import com.github.damontecres.dolphin.util.UuidSerializer
|
import com.github.damontecres.dolphin.util.UuidSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -32,14 +36,15 @@ sealed class Destination(
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
val itemId: UUID,
|
||||||
val type: BaseItemKind,
|
val type: BaseItemKind,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
|
val seasonEpisode: SeasonEpisode? = null,
|
||||||
) : Destination()
|
) : Destination()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Playback(
|
data class Playback(
|
||||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
) : Destination(true)
|
) : Destination(true)
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,20 @@ package com.github.damontecres.dolphin.ui.nav
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.detail.MediaItemContent
|
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||||
|
import com.github.damontecres.dolphin.ui.components.details.SeriesDetailParent
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.CollectionFolderDetails
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.MovieDetails
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.VideoDetails
|
||||||
import com.github.damontecres.dolphin.ui.main.MainPage
|
import com.github.damontecres.dolphin.ui.main.MainPage
|
||||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||||
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -26,14 +34,6 @@ fun DestinationContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
is Destination.MediaItem ->
|
|
||||||
MediaItemContent(
|
|
||||||
preferences = preferences,
|
|
||||||
navigationManager = navigationManager,
|
|
||||||
destination = destination,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
is Destination.Playback ->
|
is Destination.Playback ->
|
||||||
PlaybackContent(
|
PlaybackContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
@ -46,6 +46,63 @@ fun DestinationContent(
|
||||||
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
|
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
|
||||||
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
|
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
|
||||||
|
|
||||||
|
is Destination.MediaItem ->
|
||||||
|
when (destination.type) {
|
||||||
|
BaseItemKind.SERIES ->
|
||||||
|
SeriesDetailParent(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
initialSeasonEpisode = destination.seasonEpisode ?: SeasonEpisode(0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.SEASON ->
|
||||||
|
SeasonDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.EPISODE ->
|
||||||
|
EpisodeDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.MOVIE ->
|
||||||
|
MovieDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.VIDEO ->
|
||||||
|
VideoDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.COLLECTION_FOLDER -> {
|
||||||
|
CollectionFolderDetails(
|
||||||
|
preferences,
|
||||||
|
navigationManager,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Text("Unsupported item type: ${destination.type}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Destination.Search -> TODO()
|
Destination.Search -> TODO()
|
||||||
Destination.Settings -> TODO()
|
Destination.Settings -> TODO()
|
||||||
Destination.Setup -> TODO()
|
Destination.Setup -> TODO()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
data class SubtitleStream(
|
||||||
|
val index: Int,
|
||||||
|
val language: String?,
|
||||||
|
val title: String?,
|
||||||
|
val codec: String?,
|
||||||
|
val codecTag: String?,
|
||||||
|
) {
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
listOfNotNull(
|
||||||
|
language,
|
||||||
|
title,
|
||||||
|
codec,
|
||||||
|
).joinToString(" - ").ifBlank { "Unknown" }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AudioStream(
|
||||||
|
val index: Int,
|
||||||
|
val language: String?,
|
||||||
|
val title: String?,
|
||||||
|
val codec: String?,
|
||||||
|
val codecTag: String?,
|
||||||
|
val channels: Int?,
|
||||||
|
val channelLayout: String?,
|
||||||
|
) {
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
listOfNotNull(
|
||||||
|
language,
|
||||||
|
title,
|
||||||
|
codec,
|
||||||
|
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
|
||||||
|
).joinToString(" - ").ifBlank { "Unknown" }
|
||||||
|
}
|
||||||
|
|
@ -65,7 +65,7 @@ fun PlaybackContent(
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(destination.itemId) {
|
LaunchedEffect(destination.itemId) {
|
||||||
viewModel.init(destination, deviceProfile)
|
viewModel.init(destination, deviceProfile, preferences)
|
||||||
}
|
}
|
||||||
val player = viewModel.player
|
val player = viewModel.player
|
||||||
val stream by viewModel.stream.observeAsState(null)
|
val stream by viewModel.stream.observeAsState(null)
|
||||||
|
|
@ -73,6 +73,8 @@ fun PlaybackContent(
|
||||||
val title by viewModel.title.observeAsState(null)
|
val title by viewModel.title.observeAsState(null)
|
||||||
val subtitle by viewModel.subtitle.observeAsState(null)
|
val subtitle by viewModel.subtitle.observeAsState(null)
|
||||||
val duration by viewModel.duration.observeAsState(null)
|
val duration by viewModel.duration.observeAsState(null)
|
||||||
|
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
|
||||||
|
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
|
||||||
|
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
// TODO loading
|
// TODO loading
|
||||||
|
|
@ -191,14 +193,35 @@ fun PlaybackContent(
|
||||||
.background(Color.Transparent),
|
.background(Color.Transparent),
|
||||||
title = title,
|
title = title,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
captions = listOf(),
|
subtitleStreams = subtitleStreams,
|
||||||
playerControls = player,
|
playerControls = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = previousState.isEnabled,
|
previousEnabled = previousState.isEnabled,
|
||||||
nextEnabled = nextState.isEnabled,
|
nextEnabled = nextState.isEnabled,
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
onPlaybackActionClick = {},
|
onPlaybackActionClick = {
|
||||||
|
when (it) {
|
||||||
|
is PlaybackAction.PlaybackSpeed -> {
|
||||||
|
playbackSpeed = it.value
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.Scale -> {
|
||||||
|
contentScale = it.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowDebug -> TODO()
|
||||||
|
PlaybackAction.ShowPlaylist -> TODO()
|
||||||
|
PlaybackAction.ShowVideoFilterDialog -> TODO()
|
||||||
|
is PlaybackAction.ToggleAudio -> {
|
||||||
|
viewModel.changeAudioStream(it.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
|
viewModel.changeSubtitleStream(it.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
showDebugInfo = false,
|
showDebugInfo = false,
|
||||||
scale = contentScale,
|
scale = contentScale,
|
||||||
|
|
@ -206,7 +229,7 @@ fun PlaybackContent(
|
||||||
moreButtonOptions = MoreButtonOptions(mapOf()),
|
moreButtonOptions = MoreButtonOptions(mapOf()),
|
||||||
subtitleIndex = null,
|
subtitleIndex = null,
|
||||||
audioIndex = null,
|
audioIndex = null,
|
||||||
audioOptions = listOf(),
|
audioStreams = audioStreams,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.R
|
import com.github.damontecres.dolphin.R
|
||||||
import com.github.damontecres.dolphin.ui.AppColors
|
import com.github.damontecres.dolphin.ui.AppColors
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.dolphin.util.TrackSupport
|
|
||||||
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
@ -94,7 +93,7 @@ sealed interface PlaybackAction {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackControls(
|
fun PlaybackControls(
|
||||||
captions: List<TrackSupport>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
playerControls: Player,
|
playerControls: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
|
|
@ -107,7 +106,7 @@ fun PlaybackControls(
|
||||||
moreButtonOptions: MoreButtonOptions,
|
moreButtonOptions: MoreButtonOptions,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
audioOptions: List<String>,
|
audioStreams: List<AudioStream>,
|
||||||
playbackSpeed: Float,
|
playbackSpeed: Float,
|
||||||
scale: ContentScale,
|
scale: ContentScale,
|
||||||
seekBarIntervals: Int,
|
seekBarIntervals: Int,
|
||||||
|
|
@ -174,12 +173,12 @@ fun PlaybackControls(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
RightPlaybackButtons(
|
RightPlaybackButtons(
|
||||||
captions = captions,
|
subtitleStreams = subtitleStreams,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
subtitleIndex = subtitleIndex,
|
subtitleIndex = subtitleIndex,
|
||||||
audioOptions = audioOptions,
|
audioStreams = audioStreams,
|
||||||
audioIndex = audioIndex,
|
audioIndex = audioIndex,
|
||||||
playbackSpeed = playbackSpeed,
|
playbackSpeed = playbackSpeed,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
|
|
@ -300,12 +299,12 @@ private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "2.0
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RightPlaybackButtons(
|
fun RightPlaybackButtons(
|
||||||
captions: List<TrackSupport>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
onControllerInteraction: () -> Unit,
|
onControllerInteraction: () -> Unit,
|
||||||
onControllerInteractionForDialog: () -> Unit,
|
onControllerInteractionForDialog: () -> Unit,
|
||||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
audioOptions: List<String>,
|
audioStreams: List<AudioStream>,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
playbackSpeed: Float,
|
playbackSpeed: Float,
|
||||||
scale: ContentScale,
|
scale: ContentScale,
|
||||||
|
|
@ -322,7 +321,7 @@ fun RightPlaybackButtons(
|
||||||
) {
|
) {
|
||||||
// Captions
|
// Captions
|
||||||
PlaybackButton(
|
PlaybackButton(
|
||||||
enabled = captions.isNotEmpty(),
|
enabled = subtitleStreams.isNotEmpty(),
|
||||||
iconRes = R.drawable.captions_svgrepo_com,
|
iconRes = R.drawable.captions_svgrepo_com,
|
||||||
onClick = {
|
onClick = {
|
||||||
onControllerInteractionForDialog.invoke()
|
onControllerInteractionForDialog.invoke()
|
||||||
|
|
@ -343,7 +342,7 @@ fun RightPlaybackButtons(
|
||||||
}
|
}
|
||||||
if (showCaptionDialog) {
|
if (showCaptionDialog) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val options = captions.map { it.displayString(context) }
|
val options = subtitleStreams.map { it.displayName }
|
||||||
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
||||||
BottomDialog(
|
BottomDialog(
|
||||||
choices = options,
|
choices = options,
|
||||||
|
|
@ -353,7 +352,7 @@ fun RightPlaybackButtons(
|
||||||
showCaptionDialog = false
|
showCaptionDialog = false
|
||||||
},
|
},
|
||||||
onSelectChoice = { index, _ ->
|
onSelectChoice = { index, _ ->
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(index))
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.END,
|
||||||
)
|
)
|
||||||
|
|
@ -379,14 +378,14 @@ fun RightPlaybackButtons(
|
||||||
}
|
}
|
||||||
if (showAudioDialog) {
|
if (showAudioDialog) {
|
||||||
BottomDialog(
|
BottomDialog(
|
||||||
choices = audioOptions,
|
choices = audioStreams.map { it.displayName },
|
||||||
currentChoice = audioIndex,
|
currentChoice = audioIndex,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
showAudioDialog = false
|
showAudioDialog = false
|
||||||
},
|
},
|
||||||
onSelectChoice = { index, _ ->
|
onSelectChoice = { index, _ ->
|
||||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(index))
|
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index))
|
||||||
},
|
},
|
||||||
gravity = Gravity.END,
|
gravity = Gravity.END,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,11 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.util.TrackSupport
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackOverlay(
|
fun PlaybackOverlay(
|
||||||
title: String?,
|
title: String?,
|
||||||
captions: List<TrackSupport>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
playerControls: Player,
|
playerControls: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
showPlay: Boolean,
|
showPlay: Boolean,
|
||||||
|
|
@ -36,7 +35,7 @@ fun PlaybackOverlay(
|
||||||
moreButtonOptions: MoreButtonOptions,
|
moreButtonOptions: MoreButtonOptions,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
audioOptions: List<String>,
|
audioStreams: List<AudioStream>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
|
@ -63,7 +62,7 @@ fun PlaybackOverlay(
|
||||||
}
|
}
|
||||||
PlaybackControls(
|
PlaybackControls(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
captions = captions,
|
subtitleStreams = subtitleStreams,
|
||||||
playerControls = playerControls,
|
playerControls = playerControls,
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
|
|
@ -80,7 +79,7 @@ fun PlaybackOverlay(
|
||||||
moreButtonOptions = moreButtonOptions,
|
moreButtonOptions = moreButtonOptions,
|
||||||
subtitleIndex = subtitleIndex,
|
subtitleIndex = subtitleIndex,
|
||||||
audioIndex = audioIndex,
|
audioIndex = audioIndex,
|
||||||
audioOptions = audioOptions,
|
audioStreams = audioStreams,
|
||||||
playbackSpeed = playbackSpeed,
|
playbackSpeed = playbackSpeed,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
seekBarIntervals = 16,
|
seekBarIntervals = 16,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
||||||
|
import com.github.damontecres.dolphin.util.profile.PlaybackListener
|
||||||
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 kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -17,16 +19,14 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
|
||||||
import org.jellyfin.sdk.model.api.RepeatMode
|
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -39,8 +39,8 @@ enum class TranscodeType {
|
||||||
|
|
||||||
data class StreamDecision(
|
data class StreamDecision(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val url: String,
|
|
||||||
val type: TranscodeType,
|
val type: TranscodeType,
|
||||||
|
val url: String,
|
||||||
) {
|
) {
|
||||||
val mediaItem: MediaItem
|
val mediaItem: MediaItem
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -71,6 +71,11 @@ class PlaybackViewModel
|
||||||
val title = MutableLiveData<String?>(null)
|
val title = MutableLiveData<String?>(null)
|
||||||
val subtitle = MutableLiveData<String?>(null)
|
val subtitle = MutableLiveData<String?>(null)
|
||||||
val duration = MutableLiveData<Duration?>(null)
|
val duration = MutableLiveData<Duration?>(null)
|
||||||
|
val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
|
||||||
|
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
|
||||||
|
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
|
||||||
|
|
||||||
|
private lateinit var deviceProfile: DeviceProfile
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable { player.release() }
|
addCloseable { player.release() }
|
||||||
|
|
@ -79,18 +84,21 @@ class PlaybackViewModel
|
||||||
fun init(
|
fun init(
|
||||||
destination: Destination.Playback,
|
destination: Destination.Playback,
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
|
preferences: UserPreferences,
|
||||||
) {
|
) {
|
||||||
|
this.deviceProfile = deviceProfile
|
||||||
val itemId = destination.itemId
|
val itemId = destination.itemId
|
||||||
val item = destination.item
|
val item = destination.item
|
||||||
if (item != null) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
title.value = item.name
|
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||||
val base = item.data
|
|
||||||
if (item.type == BaseItemKind.EPISODE) {
|
val title = base.name
|
||||||
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
|
val subtitle =
|
||||||
val episode = base.indexNumber?.toString()?.padStart(2, '0')
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
// TODO multi episode support
|
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
|
||||||
if (season != null && episode != null) {
|
val episode = base.indexNumber?.toString()?.padStart(2, '0')
|
||||||
subtitle.value =
|
// TODO multi episode support
|
||||||
|
if (season != null && episode != null) {
|
||||||
buildString {
|
buildString {
|
||||||
if (base.seriesName != null) {
|
if (base.seriesName != null) {
|
||||||
append(base.seriesName)
|
append(base.seriesName)
|
||||||
|
|
@ -98,8 +106,84 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
append("S${season}E$episode")
|
append("S${season}E$episode")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.title.value = title
|
||||||
|
this@PlaybackViewModel.subtitle.value = subtitle
|
||||||
}
|
}
|
||||||
|
base.mediaStreams
|
||||||
|
?.filter { it.type == MediaStreamType.VIDEO }
|
||||||
|
?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
|
||||||
|
val subtitleStreams =
|
||||||
|
base.mediaStreams
|
||||||
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
?.map {
|
||||||
|
SubtitleStream(it.index, it.language, it.title, it.codec, it.codecTag)
|
||||||
|
}.orEmpty()
|
||||||
|
val audioStreams =
|
||||||
|
base.mediaStreams
|
||||||
|
?.filter { it.type == MediaStreamType.AUDIO }
|
||||||
|
?.map {
|
||||||
|
AudioStream(
|
||||||
|
it.index,
|
||||||
|
it.language,
|
||||||
|
it.title,
|
||||||
|
it.codec,
|
||||||
|
it.codecTag,
|
||||||
|
it.channels,
|
||||||
|
it.channelLayout,
|
||||||
|
)
|
||||||
|
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||||
|
.orEmpty()
|
||||||
|
// TODO use preferences to select audio/subtitle
|
||||||
|
// TODO audio selection based on channel layout, etc
|
||||||
|
val subtitleIndex = subtitleStreams.firstOrNull { it.language == "eng" }?.index
|
||||||
|
val audioIndex =
|
||||||
|
audioStreams.firstOrNull { it.language == "eng" }?.index
|
||||||
|
?: audioStreams.firstOrNull()?.index
|
||||||
|
|
||||||
|
Timber.v("subtitleTracks=$subtitleStreams")
|
||||||
|
Timber.v("audioStreams=$audioStreams")
|
||||||
|
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.audioStreams.value = audioStreams
|
||||||
|
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
|
||||||
|
val activityListener =
|
||||||
|
TrackActivityPlaybackListener(api, itemId, player)
|
||||||
|
addCloseable { activityListener.release() }
|
||||||
|
player.addListener(activityListener)
|
||||||
|
player.addListener(PlaybackListener())
|
||||||
|
changeStreams(
|
||||||
|
itemId,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
|
||||||
|
)
|
||||||
|
player.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeStreams(
|
||||||
|
itemId: UUID,
|
||||||
|
audioIndex: Int?,
|
||||||
|
subtitleIndex: Int?,
|
||||||
|
positionMs: Long = C.TIME_UNSET,
|
||||||
|
) {
|
||||||
|
if (currentPlayback.value?.let {
|
||||||
|
it.itemId == itemId &&
|
||||||
|
it.audioIndex == audioIndex &&
|
||||||
|
it.subtitleIndex == subtitleIndex
|
||||||
|
} == true
|
||||||
|
) {
|
||||||
|
Timber.i("No change in playback for changeStreams")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val response =
|
val response =
|
||||||
|
|
@ -110,72 +194,68 @@ class PlaybackViewModel
|
||||||
PlaybackInfoDto(
|
PlaybackInfoDto(
|
||||||
startTimeTicks = null,
|
startTimeTicks = null,
|
||||||
deviceProfile = deviceProfile,
|
deviceProfile = deviceProfile,
|
||||||
enableDirectStream = true,
|
|
||||||
enableDirectPlay = true,
|
enableDirectPlay = true,
|
||||||
|
enableDirectStream = true,
|
||||||
maxAudioChannels = null,
|
maxAudioChannels = null,
|
||||||
audioStreamIndex = null,
|
audioStreamIndex = audioIndex,
|
||||||
subtitleStreamIndex = null,
|
subtitleStreamIndex = subtitleIndex,
|
||||||
allowVideoStreamCopy = true,
|
allowVideoStreamCopy = true,
|
||||||
allowAudioStreamCopy = true,
|
allowAudioStreamCopy = true,
|
||||||
autoOpenLiveStream = true,
|
autoOpenLiveStream = true,
|
||||||
mediaSourceId = null,
|
mediaSourceId = null,
|
||||||
),
|
),
|
||||||
).content
|
).content
|
||||||
if (response.errorCode != null) {
|
val source = response.mediaSources.firstOrNull()
|
||||||
// TODO handle error
|
source?.let { source ->
|
||||||
throw Exception("Playback error: ${response.errorCode}")
|
val mediaUrl =
|
||||||
} else {
|
if (source.supportsDirectPlay) {
|
||||||
val source = response.mediaSources.firstOrNull()
|
|
||||||
source?.let {
|
|
||||||
val mediaUrl =
|
|
||||||
api.videosApi.getVideoStreamUrl(
|
api.videosApi.getVideoStreamUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
mediaSourceId = it.id,
|
mediaSourceId = source.id,
|
||||||
static = true,
|
static = true,
|
||||||
tag = it.eTag,
|
tag = source.eTag,
|
||||||
allowAudioStreamCopy = true,
|
|
||||||
allowVideoStreamCopy = true,
|
|
||||||
)
|
)
|
||||||
val transcodeType =
|
} else if (source.supportsDirectStream) {
|
||||||
when {
|
api.createUrl(source.transcodingUrl!!)
|
||||||
it.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
|
} else {
|
||||||
it.supportsDirectStream -> TranscodeType.DIRECT_STREAM
|
api.createUrl(source.transcodingUrl!!)
|
||||||
it.supportsTranscoding -> TranscodeType.TRANSCODE
|
|
||||||
else -> throw Exception("No supported playback method")
|
|
||||||
}
|
|
||||||
val decision = StreamDecision(itemId, mediaUrl, transcodeType)
|
|
||||||
api.playStateApi.reportPlaybackStart(
|
|
||||||
PlaybackStartInfo(
|
|
||||||
itemId = itemId,
|
|
||||||
canSeek = false,
|
|
||||||
isPaused = true,
|
|
||||||
isMuted = false,
|
|
||||||
playMethod =
|
|
||||||
when {
|
|
||||||
it.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
|
||||||
it.supportsDirectStream -> PlayMethod.DIRECT_STREAM
|
|
||||||
it.supportsTranscoding -> PlayMethod.TRANSCODE
|
|
||||||
else -> throw Exception("No supported playback method")
|
|
||||||
},
|
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val activityListener =
|
|
||||||
TrackActivityPlaybackListener(api, itemId, player)
|
|
||||||
addCloseable { activityListener.release() }
|
|
||||||
player.addListener(activityListener)
|
|
||||||
duration.value = source.runTimeTicks?.ticks
|
|
||||||
stream.value = decision
|
|
||||||
player.setMediaItem(
|
|
||||||
decision.mediaItem,
|
|
||||||
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
|
|
||||||
)
|
|
||||||
player.prepare()
|
|
||||||
}
|
}
|
||||||
|
val transcodeType =
|
||||||
|
when {
|
||||||
|
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
|
||||||
|
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM
|
||||||
|
source.supportsTranscoding -> TranscodeType.TRANSCODE
|
||||||
|
else -> throw Exception("No supported playback method")
|
||||||
|
}
|
||||||
|
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
||||||
|
Timber.v("Playback decision: $decision")
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
duration.value = source.runTimeTicks?.ticks
|
||||||
|
stream.value = decision
|
||||||
|
currentPlayback.value = CurrentPlayback(itemId, audioIndex, subtitleIndex)
|
||||||
|
player.setMediaItem(
|
||||||
|
decision.mediaItem,
|
||||||
|
positionMs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeAudioStream(index: Int) {
|
||||||
|
val itemId = currentPlayback.value?.itemId ?: return
|
||||||
|
changeStreams(itemId, index, currentPlayback.value?.subtitleIndex, player.currentPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeSubtitleStream(index: Int?) {
|
||||||
|
val itemId = currentPlayback.value?.itemId ?: return
|
||||||
|
changeStreams(itemId, currentPlayback.value?.audioIndex, index, player.currentPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CurrentPlayback(
|
||||||
|
val itemId: UUID,
|
||||||
|
val audioIndex: Int?,
|
||||||
|
val subtitleIndex: Int?,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ fun ServerList(
|
||||||
ListItem(
|
ListItem(
|
||||||
enabled = true,
|
enabled = true,
|
||||||
selected = false,
|
selected = false,
|
||||||
headlineContent = { Text(text = "Add User") },
|
headlineContent = { Text(text = "Add Server") },
|
||||||
supportingContent = { },
|
supportingContent = { },
|
||||||
onClick = { onAddServer.invoke() },
|
onClick = { onAddServer.invoke() },
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,13 @@ fun SwitchServerContent(
|
||||||
ServerList(
|
ServerList(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
connectionStatus = serverStatus,
|
connectionStatus = serverStatus,
|
||||||
onSwitchServer = {},
|
onSwitchServer = {
|
||||||
|
viewModel.addServer(it.url) {
|
||||||
|
navigationManager.navigateTo(Destination.UserList)
|
||||||
|
}
|
||||||
|
},
|
||||||
onAddServer = {
|
onAddServer = {
|
||||||
|
showAddServer = true
|
||||||
},
|
},
|
||||||
onRemoveServer = {
|
onRemoveServer = {
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ fun SwitchUserContent(
|
||||||
val currentUser = viewModel.serverRepository.currentUser
|
val currentUser = viewModel.serverRepository.currentUser
|
||||||
val users by viewModel.users.observeAsState(listOf())
|
val users by viewModel.users.observeAsState(listOf())
|
||||||
|
|
||||||
val quickConnectEnabled by viewModel.quickConnectEnabled.observeAsState(false)
|
val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf())
|
||||||
|
val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false
|
||||||
val quickConnect by viewModel.quickConnectState.observeAsState(null)
|
val quickConnect by viewModel.quickConnectState.observeAsState(null)
|
||||||
var showAddUser by remember { mutableStateOf(false) }
|
var showAddUser by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -76,7 +77,7 @@ fun SwitchUserContent(
|
||||||
if (showAddUser) {
|
if (showAddUser) {
|
||||||
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
|
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (quickConnectEnabled) {
|
if (useQuickConnect) {
|
||||||
viewModel.initiateQuickConnect(server) {
|
viewModel.initiateQuickConnect(server) {
|
||||||
navigationManager.goToHome()
|
navigationManager.goToHome()
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +91,10 @@ fun SwitchUserContent(
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (useQuickConnect) {
|
if (useQuickConnect) {
|
||||||
quickConnect?.let { qc ->
|
quickConnect?.let { qc ->
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ class SwitchUserViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
|
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
|
||||||
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
|
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
|
||||||
|
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf())
|
||||||
|
|
||||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
||||||
val quickConnectEnabled = MutableLiveData(false)
|
|
||||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
||||||
|
|
||||||
private var quickConnectJob: Job? = null
|
private var quickConnectJob: Job? = null
|
||||||
|
|
@ -57,6 +57,11 @@ class SwitchUserViewModel
|
||||||
.createApi(server.url)
|
.createApi(server.url)
|
||||||
.systemApi
|
.systemApi
|
||||||
.getPublicSystemInfo()
|
.getPublicSystemInfo()
|
||||||
|
val quickConnect by
|
||||||
|
jellyfin
|
||||||
|
.createApi(server.url)
|
||||||
|
.quickConnectApi
|
||||||
|
.getQuickConnectEnabled()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
serverStatus.value =
|
serverStatus.value =
|
||||||
serverStatus.value!!.toMutableMap().apply {
|
serverStatus.value!!.toMutableMap().apply {
|
||||||
|
|
@ -65,6 +70,10 @@ class SwitchUserViewModel
|
||||||
ServerConnectionStatus.Success,
|
ServerConnectionStatus.Success,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
serverQuickConnect.value =
|
||||||
|
serverQuickConnect.value!!.toMutableMap().apply {
|
||||||
|
put(server.id, quickConnect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error checking quick connect for server ${server.url}")
|
Timber.w(ex, "Error checking quick connect for server ${server.url}")
|
||||||
|
|
@ -88,7 +97,10 @@ class SwitchUserViewModel
|
||||||
.content
|
.content
|
||||||
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
|
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
quickConnectEnabled.value = quickConnect
|
serverQuickConnect.value =
|
||||||
|
serverQuickConnect.value!!.toMutableMap().apply {
|
||||||
|
put(it.id, quickConnect)
|
||||||
|
}
|
||||||
users.value = serverUsers
|
users.value = serverUsers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +185,10 @@ class SwitchUserViewModel
|
||||||
} catch (err: InvalidStatusException) {
|
} catch (err: InvalidStatusException) {
|
||||||
if (err.status == 401) {
|
if (err.status == 401) {
|
||||||
quickConnectState.value = null
|
quickConnectState.value = null
|
||||||
quickConnectEnabled.value = false
|
serverQuickConnect.value =
|
||||||
|
serverQuickConnect.value!!.toMutableMap().apply {
|
||||||
|
put(server.id, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +218,18 @@ class SwitchUserViewModel
|
||||||
url = serverUrl,
|
url = serverUrl,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
val quickConnect =
|
||||||
|
jellyfin
|
||||||
|
.createApi(serverUrl)
|
||||||
|
.quickConnectApi
|
||||||
|
.getQuickConnectEnabled()
|
||||||
|
.content
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
serverQuickConnect.value =
|
||||||
|
serverQuickConnect.value!!.toMutableMap().apply {
|
||||||
|
put(id, quickConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
callback.invoke()
|
callback.invoke()
|
||||||
} else {
|
} else {
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.github.damontecres.dolphin.util
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
fun formatDateTime(dateTime: LocalDateTime): String =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy")
|
||||||
|
formatter.format(dateTime)
|
||||||
|
} else if (dateTime.toString().length >= 10) {
|
||||||
|
dateTime.toString().substring(0, 10)
|
||||||
|
} else {
|
||||||
|
dateTime.toString()
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,13 @@ fun createDeviceProfile(
|
||||||
val allowedAudioCodecs =
|
val allowedAudioCodecs =
|
||||||
when {
|
when {
|
||||||
downMixAudio -> downmixSupportedAudioCodecs
|
downMixAudio -> downmixSupportedAudioCodecs
|
||||||
!isAC3Enabled -> supportedAudioCodecs.filterNot { it == Codec.Audio.EAC3 || it == Codec.Audio.AC3 }.toTypedArray()
|
!isAC3Enabled ->
|
||||||
|
supportedAudioCodecs
|
||||||
|
.filterNot {
|
||||||
|
it == Codec.Audio.EAC3 ||
|
||||||
|
it == Codec.Audio.AC3
|
||||||
|
}.toTypedArray()
|
||||||
|
|
||||||
else -> supportedAudioCodecs
|
else -> supportedAudioCodecs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +96,24 @@ fun createDeviceProfile(
|
||||||
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
|
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
|
||||||
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
|
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
|
||||||
|
|
||||||
|
// / HDR capabilities
|
||||||
|
// Display
|
||||||
|
val supportsDolbyVisionDisplay = mediaTest.supportsDolbyVision()
|
||||||
|
val supportsHdr10Display = mediaTest.supportsHdr10()
|
||||||
|
val supportsHdr10PlusDisplay = mediaTest.supportsHdr10Plus()
|
||||||
|
|
||||||
|
// Codecs
|
||||||
|
// AV1
|
||||||
|
val supportsAV1DolbyVision = mediaTest.supportsAV1DolbyVision()
|
||||||
|
val supportsAV1HDR10 = mediaTest.supportsAV1HDR10()
|
||||||
|
val supportsAV1HDR10Plus = mediaTest.supportsAV1HDR10Plus()
|
||||||
|
|
||||||
|
// HEVC
|
||||||
|
val supportsHevcDolbyVision = mediaTest.supportsHevcDolbyVision()
|
||||||
|
val supportsHevcDolbyVisionEL = mediaTest.supportsHevcDolbyVisionEL()
|
||||||
|
val supportsHevcHDR10 = mediaTest.supportsHevcHDR10()
|
||||||
|
val supportsHevcHDR10Plus = mediaTest.supportsHevcHDR10Plus()
|
||||||
|
|
||||||
name = "AndroidTV-Default"
|
name = "AndroidTV-Default"
|
||||||
|
|
||||||
// / Bitrate
|
// / Bitrate
|
||||||
|
|
@ -345,16 +369,92 @@ fun createDeviceProfile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HDR
|
// / HDR exclude list
|
||||||
|
// TODO Use VideoRangeType enum with Jelylfin 10.11 based SDK
|
||||||
|
// Display
|
||||||
codecProfile {
|
codecProfile {
|
||||||
type = CodecType.VIDEO
|
type = CodecType.VIDEO
|
||||||
|
|
||||||
conditions {
|
conditions {
|
||||||
if (!mediaTest.supportsDolbyVision()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
if (!supportsDolbyVisionDisplay) {
|
||||||
if (!mediaTest.supportsHdr10()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
||||||
if (!mediaTest.supportsHdr10Plus()) {
|
// TODO values not supported on v10.10: https://github.com/jellyfin/jellyfin/blob/v10.10.7/Jellyfin.Data/Enums/VideoRangeType.cs
|
||||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10_PLUS.serialName
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithEL"
|
||||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_ELHDR10_PLUS.serialName
|
// if (!supportsHdr10PlusDisplay) {
|
||||||
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
|
||||||
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithELHDR10Plus"
|
||||||
|
// }
|
||||||
|
if (!supportsHdr10Display) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!supportsHdr10PlusDisplay) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
|
||||||
|
if (!supportsHdr10Display) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.let {
|
||||||
|
// Remove codec profile if all HDR types are fully supported
|
||||||
|
if (it.conditions.isEmpty()) codecProfiles.remove(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codecs
|
||||||
|
// AV1
|
||||||
|
codecProfile {
|
||||||
|
type = CodecType.VIDEO
|
||||||
|
codec = Codec.Video.AV1
|
||||||
|
|
||||||
|
conditions {
|
||||||
|
if (supportsDolbyVisionDisplay && !supportsAV1DolbyVision) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
||||||
|
if (supportsHdr10Display && !supportsAV1HDR10) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
|
||||||
|
}
|
||||||
|
if (supportsHdr10PlusDisplay && !supportsAV1HDR10Plus) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (supportsHdr10PlusDisplay && !mediaTest.supportsAV1HDR10Plus()) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
|
||||||
|
if (supportsHdr10Display && !mediaTest.supportsAV1HDR10()) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.let {
|
||||||
|
// Remove codec profile if all HDR types are fully supported
|
||||||
|
if (it.conditions.isEmpty()) codecProfiles.remove(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEVC
|
||||||
|
codecProfile {
|
||||||
|
type = CodecType.VIDEO
|
||||||
|
codec = Codec.Video.HEVC
|
||||||
|
|
||||||
|
conditions {
|
||||||
|
if (supportsDolbyVisionDisplay && !supportsHevcDolbyVisionEL) {
|
||||||
|
// TODO
|
||||||
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithEL"
|
||||||
|
// if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
|
||||||
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithELHDR10Plus"
|
||||||
|
// }
|
||||||
|
if (!supportsHevcDolbyVision) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
||||||
|
if (supportsHdr10Display && !supportsHevcHDR10) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10.serialName
|
||||||
|
}
|
||||||
|
// if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
|
||||||
|
// ProfileConditionValue.VIDEO_RANGE_TYPE notEquals "DOVIWithHDR10Plus"
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (supportsHdr10PlusDisplay && !supportsHevcHDR10Plus) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10_PLUS.serialName
|
||||||
|
if (supportsHdr10Display && !supportsHevcHDR10) {
|
||||||
|
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.let {
|
}.let {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.github.damontecres.dolphin.util.profile
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.common.text.CueGroup
|
||||||
|
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
|
||||||
|
import com.github.damontecres.dolphin.util.TrackSupportReason
|
||||||
|
import com.github.damontecres.dolphin.util.TrackType
|
||||||
|
import com.github.damontecres.dolphin.util.checkForSupport
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class PlaybackListener : Player.Listener {
|
||||||
|
var mediaIndexSubtitlesActivated = -1
|
||||||
|
var currentPlaylistIndex = -2
|
||||||
|
|
||||||
|
override fun onCues(cueGroup: CueGroup) {
|
||||||
|
// val cues =
|
||||||
|
// cueGroup.cues
|
||||||
|
// .mapNotNull { it.text }
|
||||||
|
// .joinToString("\n")
|
||||||
|
// Log.v(TAG, "onCues: \n$cues")
|
||||||
|
val subtitles = cueGroup.cues.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
val trackInfo = checkForSupport(tracks)
|
||||||
|
Timber.v("Track info: $trackInfo")
|
||||||
|
val audioTracks =
|
||||||
|
trackInfo
|
||||||
|
.filter { it.type == TrackType.AUDIO && it.supported == TrackSupportReason.HANDLED }
|
||||||
|
val audioIndex = audioTracks.indexOfFirstOrNull { it.selected }
|
||||||
|
val audioOptions =
|
||||||
|
audioTracks.map { it.labels.joinToString(", ").ifBlank { "Default" } }
|
||||||
|
val captions =
|
||||||
|
trackInfo.filter { it.type == TrackType.TEXT && it.supported == TrackSupportReason.HANDLED }
|
||||||
|
|
||||||
|
// TODO user preference
|
||||||
|
val captionsByDefault = true
|
||||||
|
if (captionsByDefault && captions.isNotEmpty()) {
|
||||||
|
// TODO Captions will be empty when transitioning to new media item
|
||||||
|
// Only want to activate subtitles once in case the user turns them off
|
||||||
|
mediaIndexSubtitlesActivated = currentPlaylistIndex
|
||||||
|
val languageCode = Locale.getDefault().language
|
||||||
|
captions.indexOfFirstOrNull { it.format.language == languageCode }?.let {
|
||||||
|
Timber.v("Found default subtitle track for $languageCode: $it")
|
||||||
|
// if (toggleSubtitles(player, null, it)) {
|
||||||
|
// subtitleIndex = it
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemTransition(
|
||||||
|
mediaItem: MediaItem?,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
Timber.e(error, "Playback error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,4 +28,6 @@
|
||||||
<string name="fa_closed_captioning" translatable="false"></string>
|
<string name="fa_closed_captioning" translatable="false"></string>
|
||||||
<string name="fa_tv" translatable="false"></string>
|
<string name="fa_tv" translatable="false"></string>
|
||||||
<string name="fa_music" translatable="false"></string>
|
<string name="fa_music" translatable="false"></string>
|
||||||
|
<string name="fa_eye" translatable="false"></string>
|
||||||
|
<string name="fa_eye_slash" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,10 @@
|
||||||
<string name="home_section_livetv">Live TV</string>
|
<string name="home_section_livetv">Live TV</string>
|
||||||
<string name="home_section_none">None</string>
|
<string name="home_section_none">None</string>
|
||||||
<string name="search">Search</string>
|
<string name="search">Search</string>
|
||||||
|
<string name="resume">Resume</string>
|
||||||
|
<string name="restart">Restart</string>
|
||||||
|
<string name="play">Play</string>
|
||||||
|
<string name="more">More</string>
|
||||||
|
<string name="mark_unwatched">Mark as unwatched</string>
|
||||||
|
<string name="mark_watched">Mark as watched</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue