Lots of changes for series

This commit is contained in:
Damontecres 2025-09-30 10:55:32 -04:00
parent d7dde352bc
commit 62f557988e
No known key found for this signature in database
34 changed files with 1956 additions and 216 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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.

View file

@ -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

View file

@ -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),

View file

@ -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,
)
}
}
}

View file

@ -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,
)
}
}
}
}

View file

@ -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),
)
}
}
}
}
}

View file

@ -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,
)
}
}

View file

@ -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()

View file

@ -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,
)
} }
} }
} }

View file

@ -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)
} }

View file

@ -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}")
}
}
}

View file

@ -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")
}
}
}
}
}
}

View file

@ -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) {
}
}

View file

@ -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

View file

@ -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")
}
}
}
}
}
}

View file

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

View file

@ -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)

View file

@ -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()

View file

@ -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" }
}

View file

@ -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,
) )
} }
} }

View file

@ -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,
) )

View file

@ -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,

View file

@ -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?,
)

View file

@ -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,

View file

@ -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

View file

@ -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 ->

View file

@ -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

View file

@ -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()
}

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -28,4 +28,6 @@
<string name="fa_closed_captioning" translatable="false">&#xf20a;</string> <string name="fa_closed_captioning" translatable="false">&#xf20a;</string>
<string name="fa_tv" translatable="false">&#xf26c;</string> <string name="fa_tv" translatable="false">&#xf26c;</string>
<string name="fa_music" translatable="false">&#xf001;</string> <string name="fa_music" translatable="false">&#xf001;</string>
<string name="fa_eye" translatable="false">&#xf06e;</string>
<string name="fa_eye_slash" translatable="false">&#xf070;</string>
</resources> </resources>

View file

@ -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>