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.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -174,6 +175,9 @@ fun playOnClickSound(
val Duration.roundMinutes: Duration val Duration.roundMinutes: Duration
get() = (this + 30.seconds).inWholeMinutes.minutes get() = (this + 30.seconds).inWholeMinutes.minutes
val Duration.roundSeconds: Duration
get() = (this + 30.milliseconds).inWholeSeconds.seconds
val BaseItemDto.timeRemaining: Duration? val BaseItemDto.timeRemaining: Duration?
get() = get() =
userData?.playbackPositionTicks?.let { 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.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -17,6 +18,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.roundSeconds
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -44,21 +46,23 @@ fun ChapterCard(
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
Column( Box(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.background(AppColors.TransparentBlack50), .background(AppColors.TransparentBlack50),
) { ) {
name?.let { Column(modifier = Modifier.padding(4.dp)) {
name?.let {
Text(
text = it,
)
}
Text( Text(
text = it, text = position.roundSeconds.toString(),
) )
} }
Text(
text = position.toString(),
)
} }
} }
} }

View file

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

View file

@ -94,41 +94,40 @@ fun FocusedEpisodeHeader(
} }
} }
} }
dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor = val bgColor =
if (isFocused) { if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f) MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else { } else {
Color.Unspecified Color.Unspecified
} }
Box( 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 =
Modifier Modifier
.background(bgColor, shape = RoundedCornerShape(8.dp)) .padding(8.dp)
.playSoundOnFocus(true) .height(60.dp),
.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),
)
}
} }
} }
} }

View file

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

View file

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

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -81,32 +82,33 @@ fun PlaybackOverlay(
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value } // val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
var chapterRowFocused by remember { mutableStateOf(false) } var chapterRowFocused by remember { mutableStateOf(false) }
val titleTextStyle = MaterialTheme.typography.displaySmall val titleTextSize = 28.sp
val subtitleTextStyle = MaterialTheme.typography.headlineMedium val subtitleTextSize = 18.sp
val density = LocalDensity.current val density = LocalDensity.current
val titleHeight = 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 = 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 // Calculate height based on content
// Base height (with or w/o chapters) + title + subtitle // Base height (with or w/o chapters) + title + subtitle
// The extra 8dp is for padding between title, subtitle, and playback controls // 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 // 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( val height by animateDpAsState(
(if (chapters.isNotEmpty()) 184.dp else 140.dp) + 96.dp +
(if (chapterRowFocused) 40.dp else 0.dp) + (if (chapters.isNotEmpty()) 40.dp else 0.dp) +
(if (chapterRowFocused) 80.dp else 0.dp) +
( (
if (!chapterRowFocused && title.isNotNullOrBlank()) { if (!chapterRowFocused && title.isNotNullOrBlank()) {
titleHeight + 8.dp titleHeight + 12.dp
} else { } else {
0.dp 0.dp
} }
) + ) +
( (
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) { if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
subtitleHeight + 8.dp subtitleHeight + 12.dp
} else { } else {
0.dp 0.dp
} }
@ -132,6 +134,7 @@ fun PlaybackOverlay(
modifier = Modifier, modifier = Modifier,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -140,13 +143,15 @@ fun PlaybackOverlay(
title?.let { title?.let {
Text( Text(
text = it, text = it,
style = titleTextStyle, style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
) )
} }
subtitle?.let { subtitle?.let {
Text( Text(
text = it, 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.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport import com.github.damontecres.dolphin.util.TrackSupport
import com.github.damontecres.dolphin.util.checkForSupport 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 com.github.damontecres.dolphin.util.subtitleMimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -57,15 +57,7 @@ data class StreamDecision(
val itemId: UUID, val itemId: UUID,
val type: TranscodeType, val type: TranscodeType,
val url: String, val url: String,
) { )
val mediaItem: MediaItem
get() =
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setUri(url.toUri())
.build()
}
@HiltViewModel @HiltViewModel
class PlaybackViewModel class PlaybackViewModel
@ -112,25 +104,26 @@ class PlaybackViewModel
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
dto = base dto = base
val title = base.name val title =
if (base.type == BaseItemKind.EPISODE) {
base.seriesName
} else {
base.name
}
val subtitle = val subtitle =
if (base.type == BaseItemKind.EPISODE) { if (base.type == BaseItemKind.EPISODE) {
val season = base.parentIndexNumber?.toString()?.padStart(2, '0') val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
val episode = base.indexNumber?.toString()?.padStart(2, '0') val episode = base.indexNumber?.toString()?.padStart(2, '0')
// TODO multi episode support // TODO multi episode support
if (season != null && episode != null) { buildList {
buildString { if (season != null && episode != null) {
if (base.seriesName != null) { add("S${season}E$episode")
append(base.seriesName)
append(" - ")
}
append("S${season}E$episode")
} }
} else { add(base.name)
null add(base.premiereDate?.let { formatDateTime(it) })
} }.filterNotNull().joinToString(" - ")
} else { } else {
null base.productionYear?.toString()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title this@PlaybackViewModel.title.value = title
@ -186,7 +179,6 @@ class PlaybackViewModel
TrackActivityPlaybackListener(api, itemId, player) TrackActivityPlaybackListener(api, itemId, player)
addCloseable { activityListener.release() } addCloseable { activityListener.release() }
player.addListener(activityListener) player.addListener(activityListener)
player.addListener(PlaybackListener())
changeStreams( changeStreams(
itemId, itemId,
audioIndex, 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> <resources>
<style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar" /> <style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar">
</resources> <item name="android:colorBackground">@android:color/background_dark</item>
</style>
</resources>