mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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)
|
||||
setContent {
|
||||
val scope = rememberCoroutineScope()
|
||||
DolphinTheme {
|
||||
DolphinTheme(true) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RectangleShape,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Serializable
|
||||
|
|
@ -19,7 +21,37 @@ data class BaseItem(
|
|||
|
||||
@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 {
|
||||
fun from(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ fun CharSequence?.isNotNullOrBlank(): Boolean {
|
|||
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
|
||||
*/
|
||||
|
|
@ -61,6 +66,12 @@ fun Modifier.ifElse(
|
|||
ifFalseModifier: Modifier = Modifier,
|
||||
): 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
|
||||
* accidentally move to another element.
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ fun ItemCard(
|
|||
}
|
||||
|
||||
if (item == null) {
|
||||
NullCard(modifier, cardWidth, cardHeight)
|
||||
NullCard(modifier, cardWidth, cardHeight, interactionSource)
|
||||
} else {
|
||||
val dto = item.data
|
||||
// TODO better aspect ratio handling
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.dolphin.ui.cards
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -14,10 +15,12 @@ fun NullCard(
|
|||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 150.dp,
|
||||
cardHeight: Dp = 200.dp,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
onClick = {},
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Column(
|
||||
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() {
|
||||
if (!pager.isInitialized) {
|
||||
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 =
|
||||
GetItemsRequest(
|
||||
parentId = item.id,
|
||||
|
|
@ -58,7 +75,7 @@ class CollectionFolderViewModel
|
|||
mediaTypes = null,
|
||||
// recursive = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||
includeItemTypes = includeItemTypes,
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||
sortOrder = listOf(SortOrder.ASCENDING),
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
|
||||
|
|
@ -91,7 +108,17 @@ fun CollectionFolderDetails(
|
|||
pager?.let { pager ->
|
||||
when (library!!.collectionType) {
|
||||
CollectionType.UNKNOWN -> TODO()
|
||||
CollectionType.MOVIES -> TODO()
|
||||
|
||||
// TODO?
|
||||
CollectionType.MOVIES ->
|
||||
TVShowCollectionDetails(
|
||||
preferences,
|
||||
navigationManager,
|
||||
library!!,
|
||||
item!!,
|
||||
pager,
|
||||
modifier,
|
||||
)
|
||||
CollectionType.TVSHOWS -> {
|
||||
TVShowCollectionDetails(
|
||||
preferences,
|
||||
|
|
@ -106,7 +133,17 @@ fun CollectionFolderDetails(
|
|||
CollectionType.MUSIC -> TODO()
|
||||
CollectionType.MUSICVIDEOS -> TODO()
|
||||
CollectionType.TRAILERS -> TODO()
|
||||
CollectionType.HOMEVIDEOS -> TODO()
|
||||
|
||||
// TODO?
|
||||
CollectionType.HOMEVIDEOS ->
|
||||
TVShowCollectionDetails(
|
||||
preferences,
|
||||
navigationManager,
|
||||
library!!,
|
||||
item!!,
|
||||
pager,
|
||||
modifier,
|
||||
)
|
||||
CollectionType.BOXSETS -> TODO()
|
||||
CollectionType.BOOKS -> TODO()
|
||||
CollectionType.PHOTOS -> TODO()
|
||||
|
|
|
|||
|
|
@ -1,24 +1,43 @@
|
|||
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.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
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.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.NavigationManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -45,56 +64,99 @@ fun EpisodeDetails(
|
|||
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")
|
||||
EpisodeDetailsContent(
|
||||
item = item,
|
||||
backdropImageUrl =
|
||||
remember {
|
||||
item.data.parentBackdropItemId?.let {
|
||||
viewModel.imageUrl(it, ImageType.BACKDROP)
|
||||
}
|
||||
item {
|
||||
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||
Text(
|
||||
text = "S${dto.parentIndexNumber} E${dto.indexNumber}",
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
dto.overview?.let {
|
||||
item {
|
||||
Text(text = it)
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
}
|
||||
dto.userData?.playbackPositionTicks?.ticks?.let {
|
||||
if (it > 60.seconds) {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
navigationManager.navigateTo(
|
||||
Destination.Playback(
|
||||
item.id,
|
||||
it.inWholeMilliseconds,
|
||||
item,
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(text = "Resume")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
|
||||
},
|
||||
) {
|
||||
Text(text = "Play")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
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.launch
|
||||
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.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
|
@ -79,16 +83,22 @@ fun SeasonDetails(
|
|||
Text(text = "Loading...")
|
||||
} else {
|
||||
item?.let { item ->
|
||||
var focusedChild by remember { mutableIntStateOf(0) }
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Text(text = item.name ?: "Unknown")
|
||||
}
|
||||
item {
|
||||
}
|
||||
item {
|
||||
ItemRow(
|
||||
title = "Episodes",
|
||||
items = episodes,
|
||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||
onLongClickItem = { },
|
||||
cardOnFocus = { isFocused, index ->
|
||||
if (isFocused) focusedChild = index
|
||||
},
|
||||
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.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ class SeriesViewModel
|
|||
override fun init(
|
||||
itemId: UUID,
|
||||
potential: BaseItem?,
|
||||
): Job? =
|
||||
): Job =
|
||||
viewModelScope.launch {
|
||||
super.init(itemId, potential)?.join()
|
||||
item.value?.let { item ->
|
||||
|
|
@ -63,6 +64,51 @@ class SeriesViewModel
|
|||
ItemPager(api, request, viewModelScope, itemCount = item.data.childCount)
|
||||
pager.init()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -171,12 +170,7 @@ fun MainPage(
|
|||
title = stringResource(row.section.nameRes),
|
||||
items = row.items,
|
||||
onClickItem = {
|
||||
navigationManager.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
it.type,
|
||||
),
|
||||
)
|
||||
navigationManager.navigateTo(it.destination())
|
||||
},
|
||||
onLongClickItem = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
@file:UseSerializers(UuidSerializer::class)
|
||||
|
||||
package com.github.damontecres.dolphin.ui.nav
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -32,14 +36,15 @@ sealed class Destination(
|
|||
|
||||
@Serializable
|
||||
data class MediaItem(
|
||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||
val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val seasonEpisode: SeasonEpisode? = null,
|
||||
) : Destination()
|
||||
|
||||
@Serializable
|
||||
data class Playback(
|
||||
@Serializable(with = UuidSerializer::class) val itemId: UUID,
|
||||
val itemId: UUID,
|
||||
val positionMs: Long,
|
||||
@Transient val item: BaseItem? = null,
|
||||
) : Destination(true)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,20 @@ package com.github.damontecres.dolphin.ui.nav
|
|||
|
||||
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.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.playback.PlaybackContent
|
||||
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
|
||||
@Composable
|
||||
|
|
@ -26,14 +34,6 @@ fun DestinationContent(
|
|||
modifier = modifier,
|
||||
)
|
||||
|
||||
is Destination.MediaItem ->
|
||||
MediaItemContent(
|
||||
preferences = preferences,
|
||||
navigationManager = navigationManager,
|
||||
destination = destination,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
is Destination.Playback ->
|
||||
PlaybackContent(
|
||||
preferences = preferences,
|
||||
|
|
@ -46,6 +46,63 @@ fun DestinationContent(
|
|||
Destination.ServerList -> SwitchServerContent(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.Settings -> 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()
|
||||
LaunchedEffect(destination.itemId) {
|
||||
viewModel.init(destination, deviceProfile)
|
||||
viewModel.init(destination, deviceProfile, preferences)
|
||||
}
|
||||
val player = viewModel.player
|
||||
val stream by viewModel.stream.observeAsState(null)
|
||||
|
|
@ -73,6 +73,8 @@ fun PlaybackContent(
|
|||
val title by viewModel.title.observeAsState(null)
|
||||
val subtitle by viewModel.subtitle.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) {
|
||||
// TODO loading
|
||||
|
|
@ -191,14 +193,35 @@ fun PlaybackContent(
|
|||
.background(Color.Transparent),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
captions = listOf(),
|
||||
subtitleStreams = subtitleStreams,
|
||||
playerControls = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = previousState.isEnabled,
|
||||
nextEnabled = nextState.isEnabled,
|
||||
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,
|
||||
showDebugInfo = false,
|
||||
scale = contentScale,
|
||||
|
|
@ -206,7 +229,7 @@ fun PlaybackContent(
|
|||
moreButtonOptions = MoreButtonOptions(mapOf()),
|
||||
subtitleIndex = 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.ui.AppColors
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.util.TrackSupport
|
||||
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
@ -94,7 +93,7 @@ sealed interface PlaybackAction {
|
|||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackControls(
|
||||
captions: List<TrackSupport>,
|
||||
subtitleStreams: List<SubtitleStream>,
|
||||
playerControls: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
|
|
@ -107,7 +106,7 @@ fun PlaybackControls(
|
|||
moreButtonOptions: MoreButtonOptions,
|
||||
subtitleIndex: Int?,
|
||||
audioIndex: Int?,
|
||||
audioOptions: List<String>,
|
||||
audioStreams: List<AudioStream>,
|
||||
playbackSpeed: Float,
|
||||
scale: ContentScale,
|
||||
seekBarIntervals: Int,
|
||||
|
|
@ -174,12 +173,12 @@ fun PlaybackControls(
|
|||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
RightPlaybackButtons(
|
||||
captions = captions,
|
||||
subtitleStreams = subtitleStreams,
|
||||
onControllerInteraction = onControllerInteraction,
|
||||
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
subtitleIndex = subtitleIndex,
|
||||
audioOptions = audioOptions,
|
||||
audioStreams = audioStreams,
|
||||
audioIndex = audioIndex,
|
||||
playbackSpeed = playbackSpeed,
|
||||
scale = scale,
|
||||
|
|
@ -300,12 +299,12 @@ private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "2.0
|
|||
|
||||
@Composable
|
||||
fun RightPlaybackButtons(
|
||||
captions: List<TrackSupport>,
|
||||
subtitleStreams: List<SubtitleStream>,
|
||||
onControllerInteraction: () -> Unit,
|
||||
onControllerInteractionForDialog: () -> Unit,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
subtitleIndex: Int?,
|
||||
audioOptions: List<String>,
|
||||
audioStreams: List<AudioStream>,
|
||||
audioIndex: Int?,
|
||||
playbackSpeed: Float,
|
||||
scale: ContentScale,
|
||||
|
|
@ -322,7 +321,7 @@ fun RightPlaybackButtons(
|
|||
) {
|
||||
// Captions
|
||||
PlaybackButton(
|
||||
enabled = captions.isNotEmpty(),
|
||||
enabled = subtitleStreams.isNotEmpty(),
|
||||
iconRes = R.drawable.captions_svgrepo_com,
|
||||
onClick = {
|
||||
onControllerInteractionForDialog.invoke()
|
||||
|
|
@ -343,7 +342,7 @@ fun RightPlaybackButtons(
|
|||
}
|
||||
if (showCaptionDialog) {
|
||||
val context = LocalContext.current
|
||||
val options = captions.map { it.displayString(context) }
|
||||
val options = subtitleStreams.map { it.displayName }
|
||||
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
|
|
@ -353,7 +352,7 @@ fun RightPlaybackButtons(
|
|||
showCaptionDialog = false
|
||||
},
|
||||
onSelectChoice = { index, _ ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(index))
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
|
|
@ -379,14 +378,14 @@ fun RightPlaybackButtons(
|
|||
}
|
||||
if (showAudioDialog) {
|
||||
BottomDialog(
|
||||
choices = audioOptions,
|
||||
choices = audioStreams.map { it.displayName },
|
||||
currentChoice = audioIndex,
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
showAudioDialog = false
|
||||
},
|
||||
onSelectChoice = { index, _ ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(index))
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.media3.common.Player
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.util.TrackSupport
|
||||
|
||||
@Composable
|
||||
fun PlaybackOverlay(
|
||||
title: String?,
|
||||
captions: List<TrackSupport>,
|
||||
subtitleStreams: List<SubtitleStream>,
|
||||
playerControls: Player,
|
||||
controllerViewState: ControllerViewState,
|
||||
showPlay: Boolean,
|
||||
|
|
@ -36,7 +35,7 @@ fun PlaybackOverlay(
|
|||
moreButtonOptions: MoreButtonOptions,
|
||||
subtitleIndex: Int?,
|
||||
audioIndex: Int?,
|
||||
audioOptions: List<String>,
|
||||
audioStreams: List<AudioStream>,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
|
|
@ -63,7 +62,7 @@ fun PlaybackOverlay(
|
|||
}
|
||||
PlaybackControls(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
captions = captions,
|
||||
subtitleStreams = subtitleStreams,
|
||||
playerControls = playerControls,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
controllerViewState = controllerViewState,
|
||||
|
|
@ -80,7 +79,7 @@ fun PlaybackOverlay(
|
|||
moreButtonOptions = moreButtonOptions,
|
||||
subtitleIndex = subtitleIndex,
|
||||
audioIndex = audioIndex,
|
||||
audioOptions = audioOptions,
|
||||
audioStreams = audioStreams,
|
||||
playbackSpeed = playbackSpeed,
|
||||
scale = scale,
|
||||
seekBarIntervals = 16,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
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.util.TrackActivityPlaybackListener
|
||||
import com.github.damontecres.dolphin.util.profile.PlaybackListener
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -17,16 +19,14 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
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.model.api.BaseItemKind
|
||||
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.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
|
@ -39,8 +39,8 @@ enum class TranscodeType {
|
|||
|
||||
data class StreamDecision(
|
||||
val itemId: UUID,
|
||||
val url: String,
|
||||
val type: TranscodeType,
|
||||
val url: String,
|
||||
) {
|
||||
val mediaItem: MediaItem
|
||||
get() =
|
||||
|
|
@ -71,6 +71,11 @@ class PlaybackViewModel
|
|||
val title = MutableLiveData<String?>(null)
|
||||
val subtitle = MutableLiveData<String?>(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 {
|
||||
addCloseable { player.release() }
|
||||
|
|
@ -79,18 +84,21 @@ class PlaybackViewModel
|
|||
fun init(
|
||||
destination: Destination.Playback,
|
||||
deviceProfile: DeviceProfile,
|
||||
preferences: UserPreferences,
|
||||
) {
|
||||
this.deviceProfile = deviceProfile
|
||||
val itemId = destination.itemId
|
||||
val item = destination.item
|
||||
if (item != null) {
|
||||
title.value = item.name
|
||||
val base = item.data
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||
|
||||
val title = base.name
|
||||
val subtitle =
|
||||
if (base.type == BaseItemKind.EPISODE) {
|
||||
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
|
||||
val episode = base.indexNumber?.toString()?.padStart(2, '0')
|
||||
// TODO multi episode support
|
||||
if (season != null && episode != null) {
|
||||
subtitle.value =
|
||||
buildString {
|
||||
if (base.seriesName != null) {
|
||||
append(base.seriesName)
|
||||
|
|
@ -98,9 +106,85 @@ class PlaybackViewModel
|
|||
}
|
||||
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) {
|
||||
val response =
|
||||
api.mediaInfoApi
|
||||
|
|
@ -110,72 +194,68 @@ class PlaybackViewModel
|
|||
PlaybackInfoDto(
|
||||
startTimeTicks = null,
|
||||
deviceProfile = deviceProfile,
|
||||
enableDirectStream = true,
|
||||
enableDirectPlay = true,
|
||||
enableDirectStream = true,
|
||||
maxAudioChannels = null,
|
||||
audioStreamIndex = null,
|
||||
subtitleStreamIndex = null,
|
||||
audioStreamIndex = audioIndex,
|
||||
subtitleStreamIndex = subtitleIndex,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = true,
|
||||
mediaSourceId = null,
|
||||
),
|
||||
).content
|
||||
if (response.errorCode != null) {
|
||||
// TODO handle error
|
||||
throw Exception("Playback error: ${response.errorCode}")
|
||||
} else {
|
||||
val source = response.mediaSources.firstOrNull()
|
||||
source?.let {
|
||||
source?.let { source ->
|
||||
val mediaUrl =
|
||||
if (source.supportsDirectPlay) {
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = itemId,
|
||||
mediaSourceId = it.id,
|
||||
mediaSourceId = source.id,
|
||||
static = true,
|
||||
tag = it.eTag,
|
||||
allowAudioStreamCopy = true,
|
||||
allowVideoStreamCopy = true,
|
||||
tag = source.eTag,
|
||||
)
|
||||
} else if (source.supportsDirectStream) {
|
||||
api.createUrl(source.transcodingUrl!!)
|
||||
} else {
|
||||
api.createUrl(source.transcodingUrl!!)
|
||||
}
|
||||
val transcodeType =
|
||||
when {
|
||||
it.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
|
||||
it.supportsDirectStream -> TranscodeType.DIRECT_STREAM
|
||||
it.supportsTranscoding -> TranscodeType.TRANSCODE
|
||||
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, 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,
|
||||
),
|
||||
)
|
||||
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
||||
Timber.v("Playback decision: $decision")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val activityListener =
|
||||
TrackActivityPlaybackListener(api, itemId, player)
|
||||
addCloseable { activityListener.release() }
|
||||
player.addListener(activityListener)
|
||||
duration.value = source.runTimeTicks?.ticks
|
||||
stream.value = decision
|
||||
currentPlayback.value = CurrentPlayback(itemId, audioIndex, subtitleIndex)
|
||||
player.setMediaItem(
|
||||
decision.mediaItem,
|
||||
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
|
||||
positionMs,
|
||||
)
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = "Add User") },
|
||||
headlineContent = { Text(text = "Add Server") },
|
||||
supportingContent = { },
|
||||
onClick = { onAddServer.invoke() },
|
||||
modifier = Modifier,
|
||||
|
|
|
|||
|
|
@ -48,8 +48,13 @@ fun SwitchServerContent(
|
|||
ServerList(
|
||||
servers = servers,
|
||||
connectionStatus = serverStatus,
|
||||
onSwitchServer = {},
|
||||
onSwitchServer = {
|
||||
viewModel.addServer(it.url) {
|
||||
navigationManager.navigateTo(Destination.UserList)
|
||||
}
|
||||
},
|
||||
onAddServer = {
|
||||
showAddServer = true
|
||||
},
|
||||
onRemoveServer = {
|
||||
// TODO
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ fun SwitchUserContent(
|
|||
val currentUser = viewModel.serverRepository.currentUser
|
||||
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)
|
||||
var showAddUser by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ fun SwitchUserContent(
|
|||
if (showAddUser) {
|
||||
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (quickConnectEnabled) {
|
||||
if (useQuickConnect) {
|
||||
viewModel.initiateQuickConnect(server) {
|
||||
navigationManager.goToHome()
|
||||
}
|
||||
|
|
@ -90,7 +91,10 @@ fun SwitchUserContent(
|
|||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (useQuickConnect) {
|
||||
quickConnect?.let { qc ->
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ class SwitchUserViewModel
|
|||
) : ViewModel() {
|
||||
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
|
||||
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
|
||||
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf())
|
||||
|
||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
||||
val quickConnectEnabled = MutableLiveData(false)
|
||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
||||
|
||||
private var quickConnectJob: Job? = null
|
||||
|
|
@ -57,6 +57,11 @@ class SwitchUserViewModel
|
|||
.createApi(server.url)
|
||||
.systemApi
|
||||
.getPublicSystemInfo()
|
||||
val quickConnect by
|
||||
jellyfin
|
||||
.createApi(server.url)
|
||||
.quickConnectApi
|
||||
.getQuickConnectEnabled()
|
||||
withContext(Dispatchers.Main) {
|
||||
serverStatus.value =
|
||||
serverStatus.value!!.toMutableMap().apply {
|
||||
|
|
@ -65,6 +70,10 @@ class SwitchUserViewModel
|
|||
ServerConnectionStatus.Success,
|
||||
)
|
||||
}
|
||||
serverQuickConnect.value =
|
||||
serverQuickConnect.value!!.toMutableMap().apply {
|
||||
put(server.id, quickConnect)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error checking quick connect for server ${server.url}")
|
||||
|
|
@ -88,7 +97,10 @@ class SwitchUserViewModel
|
|||
.content
|
||||
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
|
||||
withContext(Dispatchers.Main) {
|
||||
quickConnectEnabled.value = quickConnect
|
||||
serverQuickConnect.value =
|
||||
serverQuickConnect.value!!.toMutableMap().apply {
|
||||
put(it.id, quickConnect)
|
||||
}
|
||||
users.value = serverUsers
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +185,10 @@ class SwitchUserViewModel
|
|||
} catch (err: InvalidStatusException) {
|
||||
if (err.status == 401) {
|
||||
quickConnectState.value = null
|
||||
quickConnectEnabled.value = false
|
||||
serverQuickConnect.value =
|
||||
serverQuickConnect.value!!.toMutableMap().apply {
|
||||
put(server.id, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -203,6 +218,18 @@ class SwitchUserViewModel
|
|||
url = serverUrl,
|
||||
),
|
||||
)
|
||||
val quickConnect =
|
||||
jellyfin
|
||||
.createApi(serverUrl)
|
||||
.quickConnectApi
|
||||
.getQuickConnectEnabled()
|
||||
.content
|
||||
withContext(Dispatchers.Main) {
|
||||
serverQuickConnect.value =
|
||||
serverQuickConnect.value!!.toMutableMap().apply {
|
||||
put(id, quickConnect)
|
||||
}
|
||||
}
|
||||
callback.invoke()
|
||||
} else {
|
||||
// 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 =
|
||||
when {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +96,24 @@ fun createDeviceProfile(
|
|||
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
|
||||
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"
|
||||
|
||||
// / Bitrate
|
||||
|
|
@ -345,16 +369,92 @@ fun createDeviceProfile(
|
|||
}
|
||||
}
|
||||
|
||||
// HDR
|
||||
// / HDR exclude list
|
||||
// TODO Use VideoRangeType enum with Jelylfin 10.11 based SDK
|
||||
// Display
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
|
||||
conditions {
|
||||
if (!mediaTest.supportsDolbyVision()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
||||
if (!mediaTest.supportsHdr10()) ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.HDR10.serialName
|
||||
if (!mediaTest.supportsHdr10Plus()) {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_HDR10_PLUS.serialName
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI_WITH_ELHDR10_PLUS.serialName
|
||||
if (!supportsDolbyVisionDisplay) {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals VideoRangeType.DOVI.serialName
|
||||
// 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 "DOVIWithEL"
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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_tv" 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>
|
||||
|
|
|
|||
|
|
@ -12,4 +12,10 @@
|
|||
<string name="home_section_livetv">Live TV</string>
|
||||
<string name="home_section_none">None</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue