Lots of UI tweaks

This commit is contained in:
Damontecres 2025-10-04 21:11:56 -04:00
parent 81d0279cce
commit 3109b4c003
No known key found for this signature in database
10 changed files with 87 additions and 140 deletions

View file

@ -27,6 +27,7 @@ import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@ -174,6 +175,9 @@ fun playOnClickSound(
val Duration.roundMinutes: Duration
get() = (this + 30.seconds).inWholeMinutes.minutes
val Duration.roundSeconds: Duration
get() = (this + 30.milliseconds).inWholeSeconds.seconds
val BaseItemDto.timeRemaining: Duration?
get() =
userData?.playbackPositionTicks?.let {

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -17,6 +18,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.roundSeconds
import kotlin.time.Duration
@Composable
@ -44,21 +46,23 @@ fun ChapterCard(
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
Column(
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
) {
name?.let {
Column(modifier = Modifier.padding(4.dp)) {
name?.let {
Text(
text = it,
)
}
Text(
text = it,
text = position.roundSeconds.toString(),
)
}
Text(
text = position.toString(),
)
}
}
}

View file

@ -194,7 +194,7 @@ fun MovieDetailsContent(
modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 120.dp),
.padding(bottom = 56.dp),
) {
MovieDetailsHeader(
movie = movie,

View file

@ -94,41 +94,40 @@ fun FocusedEpisodeHeader(
}
}
}
dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} 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 = dto.overview ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
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.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(60.dp),
)
}
.padding(8.dp)
.height(60.dp),
)
}
}
}

View file

@ -188,7 +188,7 @@ fun SeriesOverviewContent(
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)

View file

@ -142,6 +142,7 @@ fun PlaybackControls(
Column(
modifier = modifier.bringIntoViewRequester(bringIntoViewRequester),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
SeekBar(
player = playerControls,
@ -152,7 +153,7 @@ fun PlaybackControls(
intervals = seekBarIntervals,
modifier =
Modifier
.padding(vertical = 8.dp)
.padding(vertical = 0.dp)
.fillMaxWidth(.95f),
)
Box(
@ -516,11 +517,11 @@ fun PlaybackButton(
containerColor = AppColors.TransparentBlack25,
focusedContainerColor = selectedColor,
),
contentPadding = PaddingValues(8.dp),
contentPadding = PaddingValues(4.dp),
modifier =
modifier
.padding(8.dp)
.size(56.dp, 56.dp)
.padding(4.dp)
.size(44.dp, 44.dp)
.onFocusChanged { onControllerInteraction.invoke() },
) {
Icon(

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -81,32 +82,33 @@ fun PlaybackOverlay(
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
var chapterRowFocused by remember { mutableStateOf(false) }
val titleTextStyle = MaterialTheme.typography.displaySmall
val subtitleTextStyle = MaterialTheme.typography.headlineMedium
val titleTextSize = 28.sp
val subtitleTextSize = 18.sp
val density = LocalDensity.current
val titleHeight =
if (title.isNotNullOrBlank()) with(density) { titleTextStyle.fontSize.toDp() } else 0.dp
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
val subtitleHeight =
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextStyle.fontSize.toDp() } else 0.dp
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
// Calculate height based on content
// Base height (with or w/o chapters) + title + subtitle
// The extra 8dp is for padding between title, subtitle, and playback controls
// When chapter row is focused, the title/subtitle/playback controls will be hidden, but need extra height for the chapter images
val height by animateDpAsState(
(if (chapters.isNotEmpty()) 184.dp else 140.dp) +
(if (chapterRowFocused) 40.dp else 0.dp) +
96.dp +
(if (chapters.isNotEmpty()) 40.dp else 0.dp) +
(if (chapterRowFocused) 80.dp else 0.dp) +
(
if (!chapterRowFocused && title.isNotNullOrBlank()) {
titleHeight + 8.dp
titleHeight + 12.dp
} else {
0.dp
}
) +
(
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
subtitleHeight + 8.dp
subtitleHeight + 12.dp
} else {
0.dp
}
@ -132,6 +134,7 @@ fun PlaybackOverlay(
modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
@ -140,13 +143,15 @@ fun PlaybackOverlay(
title?.let {
Text(
text = it,
style = titleTextStyle,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
)
}
subtitle?.let {
Text(
text = it,
style = subtitleTextStyle,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
)
}
}

View file

@ -21,7 +21,7 @@ import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport
import com.github.damontecres.dolphin.util.checkForSupport
import com.github.damontecres.dolphin.util.profile.PlaybackListener
import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.subtitleMimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@ -57,15 +57,7 @@ data class StreamDecision(
val itemId: UUID,
val type: TranscodeType,
val url: String,
) {
val mediaItem: MediaItem
get() =
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setUri(url.toUri())
.build()
}
)
@HiltViewModel
class PlaybackViewModel
@ -112,25 +104,26 @@ class PlaybackViewModel
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
dto = base
val title = base.name
val title =
if (base.type == BaseItemKind.EPISODE) {
base.seriesName
} else {
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) {
buildString {
if (base.seriesName != null) {
append(base.seriesName)
append(" - ")
}
append("S${season}E$episode")
buildList {
if (season != null && episode != null) {
add("S${season}E$episode")
}
} else {
null
}
add(base.name)
add(base.premiereDate?.let { formatDateTime(it) })
}.filterNotNull().joinToString(" - ")
} else {
null
base.productionYear?.toString()
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title
@ -186,7 +179,6 @@ class PlaybackViewModel
TrackActivityPlaybackListener(api, itemId, player)
addCloseable { activityListener.release() }
player.addListener(activityListener)
player.addListener(PlaybackListener())
changeStreams(
itemId,
audioIndex,

View file

@ -1,60 +0,0 @@
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 subtitles = cueGroup.cues.ifEmpty { null }
}
override fun onTracksChanged(tracks: Tracks) {
val trackInfo = checkForSupport(tracks)
Timber.v("Track info: $trackInfo")
val audioTracks =
trackInfo
.filter { it.type == TrackType.AUDIO && it.supported == TrackSupportReason.HANDLED }
val audioIndex = audioTracks.indexOfFirstOrNull { it.selected }
val audioOptions =
audioTracks.map { it.labels.joinToString(", ").ifBlank { "Default" } }
val captions =
trackInfo.filter { it.type == TrackType.TEXT && it.supported == TrackSupportReason.HANDLED }
// TODO user preference
val captionsByDefault = true
if (captionsByDefault && captions.isNotEmpty()) {
// TODO Captions will be empty when transitioning to new media item
// Only want to activate subtitles once in case the user turns them off
mediaIndexSubtitlesActivated = currentPlaylistIndex
val languageCode = Locale.getDefault().language
captions.indexOfFirstOrNull { it.format.language == languageCode }?.let {
Timber.v("Found default subtitle track for $languageCode: $it")
// if (toggleSubtitles(player, null, it)) {
// subtitleIndex = it
// }
}
}
}
override fun onMediaItemTransition(
mediaItem: MediaItem?,
reason: Int,
) {
}
override fun onPlayerError(error: PlaybackException) {
Timber.e(error, "Playback error")
}
}

View file

@ -1,4 +1,6 @@
<resources>
<style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:colorBackground">@android:color/background_dark</item>
</style>
</resources>