Various UI/UX tweaks

This commit is contained in:
Damontecres 2025-10-07 21:14:33 -04:00
parent 005d6b11ce
commit 2212e32c53
No known key found for this signature in database
22 changed files with 237 additions and 149 deletions

View file

@ -9,8 +9,10 @@ class DolphinApplication : Application() {
init { init {
instance = this instance = this
// TODO only plant in debug builds if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) // TODO minimal logging for release builds?
Timber.plant(Timber.DebugTree())
}
} }
companion object { companion object {

View file

@ -65,7 +65,6 @@ fun SortByButton(
) )
} }
// TODO switch to dialog?
DropdownMenu( DropdownMenu(
expanded = sortByDropDown, expanded = sortByDropDown,
onDismissRequest = { sortByDropDown = false }, onDismissRequest = { sortByDropDown = false },

View file

@ -1,6 +1,5 @@
package com.github.damontecres.dolphin.ui.components package com.github.damontecres.dolphin.ui.components
import android.widget.Toast
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -51,11 +50,6 @@ fun SwitchWithLabel(
onClick = { onClick = {
if (enabled) { if (enabled) {
onStateChange(!checked) onStateChange(!checked)
} else {
// TODO there are other uses, so shouldn't hardcode this toast
Toast
.makeText(context, "Item has no children", Toast.LENGTH_SHORT)
.show()
} }
}, },
).padding(8.dp), ).padding(8.dp),

View file

@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
@ -253,6 +254,7 @@ fun CardGrid(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusGroup() .focusGroup()
.focusRestorer(firstFocus)
.focusRequester(gridFocusRequester) .focusRequester(gridFocusRequester)
.focusProperties { .focusProperties {
onExit = { onExit = {
@ -264,6 +266,7 @@ fun CardGrid(
onEnter = { onEnter = {
focusedIndexOnExit = -1 focusedIndexOnExit = -1
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
Timber.d("onEnter: focusedIndex=$focusedIndex, savedFocusedIndex=$savedFocusedIndex")
focusedIndex = startPosition focusedIndex = startPosition
firstFocus.tryRequestFocus() firstFocus.tryRequestFocus()
} }
@ -317,7 +320,12 @@ fun CardGrid(
} }
}, },
item = item, item = item,
onClick = { if (item != null) itemOnClick.invoke(item) }, onClick = {
if (item != null) {
itemOnClick.invoke(item)
savedFocusedIndex = index
}
},
onLongClick = { if (item != null) longClicker.invoke(item) }, onLongClick = { if (item != null) longClicker.invoke(item) },
cardHeight = null, cardHeight = null,
) )

View file

@ -1,5 +1,10 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -8,7 +13,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -136,6 +144,7 @@ fun CollectionFolderDetails(
SortOrder.ASCENDING, SortOrder.ASCENDING,
), ),
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item, initialSortAndDirection) viewModel.init(destination.itemId, destination.item, initialSortAndDirection)
@ -163,6 +172,7 @@ fun CollectionFolderDetails(
viewModel.loadResults(it) viewModel.loadResults(it)
}, },
showTitle = showTitle, showTitle = showTitle,
positionCallback = positionCallback,
) )
} }
} }
@ -180,6 +190,7 @@ fun CollectionDetails(
onSortChange: (SortAndDirection) -> Unit, onSortChange: (SortAndDirection) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection" val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection"
val sortOptions = val sortOptions =
@ -190,27 +201,35 @@ fun CollectionDetails(
else -> listOf(ItemSortBy.SORT_NAME, ItemSortBy.DATE_CREATED, ItemSortBy.RANDOM) else -> listOf(ItemSortBy.SORT_NAME, ItemSortBy.DATE_CREATED, ItemSortBy.RANDOM)
} }
var showHeader by rememberSaveable { mutableStateOf(true) }
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier, modifier = modifier,
) { ) {
if (showTitle) { AnimatedVisibility(
Text( showHeader,
text = title, enter = slideInVertically() + fadeIn(),
style = MaterialTheme.typography.displayMedium, exit = slideOutVertically() + fadeOut(),
color = MaterialTheme.colorScheme.onBackground, ) {
textAlign = TextAlign.Center, if (showTitle) {
modifier = Modifier.fillMaxWidth(), Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
) )
} }
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
CardGrid( CardGrid(
pager = pager, pager = pager,
itemOnClick = { itemOnClick = {
@ -229,7 +248,10 @@ fun CollectionDetails(
showJumpButtons = false, // TODO add preference showJumpButtons = false, // TODO add preference
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
initialPosition = 0, initialPosition = 0,
positionCallback = { _, _ -> }, positionCallback = { columns, position ->
showHeader = position < columns
positionCallback?.invoke(columns, position)
},
) )
} }
} }

View file

@ -1,5 +1,10 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.padding import androidx.compose.foundation.layout.padding
@ -7,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -64,62 +70,69 @@ fun CollectionFolderMovie(
} }
} }
} }
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
TabRow( AnimatedVisibility(
selectedTabIndex = focusTabIndex, showHeader,
modifier = enter = slideInVertically() + fadeIn(),
Modifier exit = slideOutVertically() + fadeOut(),
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) ) {
.focusRestorer(firstTabFocusRequester) TabRow(
.onFocusChanged { selectedTabIndex = focusTabIndex,
if (!it.isFocused) { modifier =
focusTabIndex = selectedTabIndex Modifier
} .padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
}, .focusRestorer(firstTabFocusRequester)
indicator = .onFocusChanged {
@Composable { tabPositions, doesTabRowHaveFocus -> if (!it.isFocused) {
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator( // TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition, // currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus, // doesTabRowHaveFocus = doesTabRowHaveFocus,
// ) // )
TabRowDefaults.UnderlinedIndicator( TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition, currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus, doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border, activeColor = MaterialTheme.colorScheme.border,
) )
}
},
tabs = {
tabs.forEachIndexed { index, title ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
} }
}, },
tabs = { )
tabs.forEachIndexed { index, title -> }
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> { 0 -> {
RecommendedMovie( RecommendedMovie(
@ -144,6 +157,9 @@ fun CollectionFolderMovie(
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
) )
} }
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -1,5 +1,10 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.padding import androidx.compose.foundation.layout.padding
@ -7,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -65,61 +71,69 @@ fun CollectionFolderTv(
} }
} }
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
TabRow( AnimatedVisibility(
selectedTabIndex = focusTabIndex, showHeader,
modifier = enter = slideInVertically() + fadeIn(),
Modifier exit = slideOutVertically() + fadeOut(),
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) ) {
.focusRestorer(firstTabFocusRequester) TabRow(
.onFocusChanged { selectedTabIndex = focusTabIndex,
if (!it.isFocused) { modifier =
focusTabIndex = selectedTabIndex Modifier
} .padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
}, .focusRestorer(firstTabFocusRequester)
indicator = .onFocusChanged {
@Composable { tabPositions, doesTabRowHaveFocus -> if (!it.isFocused) {
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator( // TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition, // currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus, // doesTabRowHaveFocus = doesTabRowHaveFocus,
// ) // )
TabRowDefaults.UnderlinedIndicator( TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition, currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus, doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border, activeColor = MaterialTheme.colorScheme.border,
) )
}
},
tabs = {
tabs.forEachIndexed { index, title ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
} }
}, },
tabs = { )
tabs.forEachIndexed { index, title -> }
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> { 0 -> {
RecommendedTvShow( RecommendedTvShow(
@ -144,6 +158,9 @@ fun CollectionFolderTv(
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
) )
} }
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -225,7 +225,7 @@ class SeriesViewModel
try { try {
loadEpisodesInternal(season) loadEpisodesInternal(season)
} catch (e: Exception) { } catch (e: Exception) {
Timber.Forest.e(e, "Error loading episodes for $seriesId for season $season") Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI? // TODO show error in UI?
ItemListAndMapping.empty() ItemListAndMapping.empty()
} }

View file

@ -90,9 +90,10 @@ class MovieViewModel
super.init(itemId, potential)?.join() super.init(itemId, potential)?.join()
item.value?.let { item -> item.value?.let { item ->
people.value = people.value =
item.data.people?.letNotEmpty { people -> item.data.people
people.map { Person.fromDto(it, api) } ?.letNotEmpty { people ->
} people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api) chapters.value = Chapter.fromDto(item.data, api)
} }
} }

View file

@ -43,6 +43,7 @@ import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.ui.timeRemaining
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
@ -93,13 +94,13 @@ fun MovieDetailsHeader(
add(it) add(it)
} }
dto.timeRemaining?.roundMinutes?.let { add("$it left") } dto.timeRemaining?.roundMinutes?.let { add("$it left") }
dto.officialRating?.let(::add)
} }
DotSeparatedRow( DotSeparatedRow(
texts = details, texts = details,
textStyle = MaterialTheme.typography.titleLarge, textStyle = MaterialTheme.typography.titleLarge,
) )
} }
// TODO ratings?
dto.communityRating?.let { dto.communityRating?.let {
if (it > 0f) { if (it > 0f) {
StarRating( StarRating(
@ -196,15 +197,21 @@ fun MovieDetailsHeader(
) )
} }
} }
// TODO director // TODO add writers, studio, etc to overview dialog
// TODO writers dto.people?.firstOrNull { it.type == PersonKind.DIRECTOR }?.name?.let {
dto.studios?.letNotEmpty {
TitleValueText( TitleValueText(
stringResource(R.string.studios), stringResource(R.string.director),
it.joinToString(", ") { s -> s.name ?: "" }, it,
modifier = Modifier.widthIn(max = 80.dp), modifier = Modifier.widthIn(max = 80.dp),
) )
} }
// dto.studios?.letNotEmpty {
// TitleValueText(
// stringResource(R.string.studios),
// it.joinToString(", ") { s -> s.name ?: "" },
// modifier = Modifier.widthIn(max = 80.dp),
// )
// }
dto.genres?.letNotEmpty { dto.genres?.letNotEmpty {
TitleValueText( TitleValueText(
stringResource(R.string.genres), stringResource(R.string.genres),

View file

@ -67,6 +67,7 @@ fun FocusedEpisodeHeader(
add(it) add(it)
} }
dto.timeRemaining?.roundMinutes?.let { add("$it left") } dto.timeRemaining?.roundMinutes?.let { add("$it left") }
dto.officialRating?.let(::add)
} }
DotSeparatedRow( DotSeparatedRow(
texts = details, texts = details,
@ -77,7 +78,6 @@ fun FocusedEpisodeHeader(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// TODO ratings?
dto.communityRating?.let { dto.communityRating?.let {
if (it > 0f) { if (it > 0f) {
StarRating( StarRating(

View file

@ -80,7 +80,6 @@ fun SeriesOverview(
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty()) val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty())
// TODO need to translate season/episode into tab/row index in case of missing seasons/episodes
var position by rememberSaveable( var position by rememberSaveable(
destination, destination,
loading, loading,
@ -103,7 +102,7 @@ fun SeriesOverview(
LaunchedEffect(episodes.items) { LaunchedEffect(episodes.items) {
if (episodes.items.isNotEmpty()) { if (episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons // TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus() // firstItemFocusRequester.requestFocus()
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex) viewModel.refreshEpisode(it.id, position.episodeRowIndex)

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -38,6 +39,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
@ -67,7 +69,7 @@ fun SeriesOverviewContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
// TODO need to map between season index and tab index in case of missing seasons var focusTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } } val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
@ -121,7 +123,7 @@ fun SeriesOverviewContent(
) { ) {
item { item {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = focusTabIndex,
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
@ -129,20 +131,39 @@ fun SeriesOverviewContent(
{ Modifier.focusRestorer(focusRequesters[selectedTabIndex]) }, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) },
).focusRequester(tabRowFocusRequester) ).focusRequester(tabRowFocusRequester)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth()
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
) { ) {
seasons.forEachIndexed { index, season -> seasons.forEachIndexed { index, season ->
season?.let { season -> season?.let { season ->
Tab( Tab(
selected = index == selectedTabIndex, selected = focusTabIndex == index,
onFocus = {}, onFocus = { focusTabIndex = index },
onClick = { onClick = {
selectedTabIndex = index selectedTabIndex = index
onFocus.invoke(SeriesOverviewPosition(index, 0)) onFocus.invoke(SeriesOverviewPosition(index, 0))
}, },
colors = colors =
TabDefaults.pillIndicatorTabColors( TabDefaults.pillIndicatorTabColors(
// TODO focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
), ),
modifier = modifier =
Modifier Modifier

View file

@ -96,7 +96,7 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) mutableStateOf(RowColumn(0, 0))
} }
// TODO use position instead?
var focusedItem by remember { var focusedItem by remember {
mutableStateOf<BaseItem?>( mutableStateOf<BaseItem?>(
homeRows.getOrNull(0)?.items?.getOrNull( homeRows.getOrNull(0)?.items?.getOrNull(
@ -178,7 +178,7 @@ fun HomePageContent(
}, },
onLongClickItem = {}, onLongClickItem = {},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, modifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling? // TODO better aspect ration handling?
BannerCard( BannerCard(
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
@ -189,7 +189,7 @@ fun HomePageContent(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier =
modifier cardModifier
.ifElse( .ifElse(
focusedItem == item, focusedItem == item,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),

View file

@ -16,9 +16,6 @@ import java.util.UUID
sealed class Destination( sealed class Destination(
val fullScreen: Boolean = false, val fullScreen: Boolean = false,
) : NavKey { ) : NavKey {
@Serializable
data object Setup : Destination(true)
@Serializable @Serializable
data object ServerList : Destination(true) data object ServerList : Destination(true)

View file

@ -11,7 +11,6 @@ import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails
import com.github.damontecres.dolphin.ui.detail.SeriesDetails import com.github.damontecres.dolphin.ui.detail.SeriesDetails
import com.github.damontecres.dolphin.ui.detail.VideoDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
import com.github.damontecres.dolphin.ui.main.HomePage import com.github.damontecres.dolphin.ui.main.HomePage
@ -105,7 +104,8 @@ fun DestinationContent(
) )
BaseItemKind.VIDEO -> BaseItemKind.VIDEO ->
VideoDetails( // TODO Use VideoDetails
MovieDetails(
preferences, preferences,
navigationManager, navigationManager,
destination, destination,
@ -153,6 +153,5 @@ fun DestinationContent(
userPreferences = preferences, userPreferences = preferences,
modifier = modifier, modifier = modifier,
) )
Destination.Setup -> TODO()
} }
} }

View file

@ -120,7 +120,7 @@ fun NavDrawer(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val drawerFocusRequester = remember { FocusRequester() } val drawerFocusRequester = remember { FocusRequester() }
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination == Destination.Main)) { BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Main)) {
drawerState.setValue(DrawerValue.Open) drawerState.setValue(DrawerValue.Open)
drawerFocusRequester.requestFocus() drawerFocusRequester.requestFocus()
} }

View file

@ -121,7 +121,7 @@ fun PlaybackContent(
AmbientPlayerListener(player) AmbientPlayerListener(player)
if (stream == null) { if (stream == null) {
// TODO loading LoadingPage()
} else { } else {
stream?.let { stream?.let {
var contentScale by remember { mutableStateOf(ContentScale.Fit) } var contentScale by remember { mutableStateOf(ContentScale.Fit) }

View file

@ -288,7 +288,6 @@ fun LeftPlaybackButtons(
) )
} }
if (showMoreOptions) { if (showMoreOptions) {
// TODO options need context about what to display
val options = val options =
buildList { buildList {
addAll(moreButtonOptions.options.keys) addAll(moreButtonOptions.options.keys)

View file

@ -73,7 +73,7 @@ fun ServerList(
}, },
onClick = { onSwitchServer.invoke(server) }, onClick = { onSwitchServer.invoke(server) },
onLongClick = { onLongClick = {
// TODO dialog to remove server showDeleteDialog = server
}, },
modifier = Modifier, modifier = Modifier,
) )

View file

@ -15,10 +15,11 @@ fun formatDateTime(dateTime: LocalDateTime): String =
dateTime.toString() dateTime.toString()
} }
// TODO multi episode support
val BaseItemDto.seasonEpisode: String? val BaseItemDto.seasonEpisode: String?
get() = get() =
if (parentIndexNumber != null && indexNumber != null) { if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
} else if (parentIndexNumber != null && indexNumber != null) {
"S$parentIndexNumber E$indexNumber" "S$parentIndexNumber E$indexNumber"
} else { } else {
null null
@ -29,7 +30,12 @@ val BaseItemDto.seasonEpisodePadded: String?
if (parentIndexNumber != null && indexNumber != null) { if (parentIndexNumber != null && indexNumber != null) {
val season = parentIndexNumber?.toString()?.padStart(2, '0') val season = parentIndexNumber?.toString()?.padStart(2, '0')
val episode = indexNumber?.toString()?.padStart(2, '0') val episode = indexNumber?.toString()?.padStart(2, '0')
"S${season}E$episode" val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
if (endEpisode != null) {
"S${season}E$episode-E$endEpisode"
} else {
"S${season}E$episode"
}
} else { } else {
null null
} }

View file

@ -60,5 +60,6 @@
<string name="pref_key_update_last_check">preference.update.lastTimestamp</string> <string name="pref_key_update_last_check">preference.update.lastTimestamp</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="remember_selected_tab">Remember selected tabs</string> <string name="remember_selected_tab">Remember selected tabs</string>
<string name="director">Director</string>
</resources> </resources>