feat: tv UI work.

This commit is contained in:
oxy-macmini
2025-03-29 20:35:21 +08:00
parent 954ae6357d
commit 87c2ae2993
8 changed files with 188 additions and 219 deletions

View File

@ -48,6 +48,13 @@ android {
packaging {
resources.excludes += "META-INF/**"
}
applicationVariants.all {
outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
output.versionNameOverride = "tv-${versionName}.apk"
}
}
}
hilt {

View File

@ -2,14 +2,17 @@ package com.m3u.tv.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.focusGroup
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
@ -17,44 +20,38 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.LazyPagingItems
import androidx.tv.material3.Border
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.CompactCard
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.m3u.data.database.model.Channel
import com.m3u.tv.screens.dashboard.rememberChildPadding
import com.m3u.tv.theme.JetStreamBorderWidth
enum class ItemDirection(val aspectRatio: Float) {
Vertical(10.5f / 16f),
Horizontal(16f / 9f);
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ChannelsRow(
channels: List<Channel>,
modifier: Modifier = Modifier,
itemDirection: ItemDirection = ItemDirection.Vertical,
startPadding: Dp = rememberChildPadding().start,
endPadding: Dp = rememberChildPadding().end,
title: String? = null,
@ -62,8 +59,6 @@ fun ChannelsRow(
fontWeight = FontWeight.Medium,
fontSize = 30.sp
),
showItemTitle: Boolean = true,
showIndexOverImage: Boolean = false,
onChannelSelected: (channel: Channel) -> Unit = {}
) {
val (lazyRow, firstItem) = remember { FocusRequester.createRefs() }
@ -104,15 +99,11 @@ fun ChannelsRow(
}
ChannelsRowItem(
modifier = itemModifier.weight(1f),
index = index,
itemDirection = itemDirection,
onChannelSelected = {
lazyRow.saveFocusedChild()
onChannelSelected(it)
},
channel = channel,
showItemTitle = showItemTitle,
showIndexOverImage = showIndexOverImage
channel = channel
)
}
}
@ -120,12 +111,40 @@ fun ChannelsRow(
}
}
@Composable
fun ChannelsRow(
channels: LazyPagingItems<Channel>,
modifier: Modifier = Modifier,
startPadding: Dp = rememberChildPadding().start,
endPadding: Dp = rememberChildPadding().end,
onChannelSelected: (channel: Channel) -> Unit = {}
) {
LazyRow(
contentPadding = PaddingValues(
start = startPadding,
end = endPadding,
),
horizontalArrangement = Arrangement.spacedBy(20.dp),
modifier = modifier
) {
items(channels.itemCount) { index ->
val channel = channels[index]
if (channel != null) {
ChannelsRowItem(
modifier = Modifier.fillParentMaxHeight(),
onChannelSelected = onChannelSelected,
channel = channel,
)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ImmersiveListChannelsRow(
channels: List<Channel>,
modifier: Modifier = Modifier,
itemDirection: ItemDirection = ItemDirection.Vertical,
startPadding: Dp = rememberChildPadding().start,
endPadding: Dp = rememberChildPadding().end,
title: String? = null,
@ -133,10 +152,7 @@ fun ImmersiveListChannelsRow(
fontWeight = FontWeight.Medium,
fontSize = 30.sp
),
showItemTitle: Boolean = true,
showIndexOverImage: Boolean = false,
onChannelSelected: (Channel) -> Unit = {},
onChannelFocused: (Channel) -> Unit = {}
) {
val (lazyRow, firstItem) = remember { FocusRequester.createRefs() }
@ -179,16 +195,11 @@ fun ImmersiveListChannelsRow(
}
ChannelsRowItem(
modifier = itemModifier.weight(1f),
index = index,
itemDirection = itemDirection,
onChannelSelected = {
lazyRow.saveFocusedChild()
onChannelSelected(it)
},
onChannelFocused = onChannelFocused,
channel = channel,
showItemTitle = showItemTitle,
showIndexOverImage = showIndexOverImage
)
}
}
@ -196,119 +207,73 @@ fun ImmersiveListChannelsRow(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ChannelsRowItem(
index: Int,
channel: Channel,
onChannelSelected: (Channel) -> Unit,
showItemTitle: Boolean,
showIndexOverImage: Boolean,
modifier: Modifier = Modifier,
itemDirection: ItemDirection = ItemDirection.Vertical,
onChannelFocused: (Channel) -> Unit = {},
itemWidth: Dp = 432.dp
) {
var isFocused by remember { mutableStateOf(false) }
ChannelCard(
onClick = { onChannelSelected(channel) },
title = {
ChannelsRowItemText(
showItemTitle = showItemTitle,
isItemFocused = isFocused,
channel = channel
)
},
modifier = Modifier
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onChannelFocused(channel)
}
}
.focusProperties {
left = if (index == 0) {
FocusRequester.Cancel
} else {
FocusRequester.Default
}
}
.then(modifier)
) {
ChannelsRowItemImage(
modifier = Modifier.aspectRatio(itemDirection.aspectRatio),
showIndexOverImage = showIndexOverImage,
channel = channel,
index = index
)
}
}
@Composable
private fun ChannelsRowItemImage(
channel: Channel,
showIndexOverImage: Boolean,
index: Int,
modifier: Modifier = Modifier,
) {
Box(contentAlignment = Alignment.CenterStart) {
PosterImage(
channel = channel,
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(JetStreamBorderWidth))
var isFocused by remember { mutableStateOf(false) }
CompactCard(
modifier = modifier
.fillMaxWidth()
.drawWithContent {
drawContent()
if (showIndexOverImage) {
drawRect(
color = Color.Black.copy(
alpha = 0.1f
)
)
}
},
)
if (showIndexOverImage) {
Text(
modifier = Modifier.padding(16.dp),
text = "#${index.inc()}",
style = MaterialTheme.typography.displayLarge
.copy(
shadow = Shadow(
offset = Offset(0.5f, 0.5f),
blurRadius = 5f
),
color = Color.White
),
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
private fun ChannelsRowItemText(
showItemTitle: Boolean,
isItemFocused: Boolean,
channel: Channel,
modifier: Modifier = Modifier
) {
if (showItemTitle) {
val channelNameAlpha by animateFloatAsState(
targetValue = if (isItemFocused) 1f else 0f,
label = "",
)
Text(
text = channel.title,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
.width(itemWidth)
.aspectRatio(2f)
.padding(end = 32.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus },
scale = CardDefaults.scale(focusedScale = 1f),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(
width = JetStreamBorderWidth, color = MaterialTheme.colorScheme.onSurface
)
)
),
textAlign = TextAlign.Center,
modifier = modifier
.alpha(channelNameAlpha)
.fillMaxWidth()
.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
colors = CardDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
),
onClick = { onChannelSelected(channel) },
image = {
val contentAlpha by animateFloatAsState(
targetValue = if (isFocused) 1f else 0.5f,
label = "",
)
AsyncImage(
model = channel.cover,
contentDescription = channel.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = contentAlpha }
)
},
title = {
Column {
Text(
text = channel.category,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Normal
),
modifier = Modifier
.graphicsLayer { alpha = 0.6f }
.padding(start = 24.dp)
)
Text(
text = channel.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(
start = 24.dp,
end = 24.dp,
bottom = 24.dp
),
// TODO: Remove this when CardContent is not overriding contentColor anymore
color = MaterialTheme.colorScheme.onSurface
)
}
}
)
}
}

View File

@ -33,7 +33,6 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.m3u.data.database.model.Channel
import com.m3u.tv.common.ImmersiveListChannelsRow
import com.m3u.tv.common.ItemDirection
import com.m3u.tv.common.PosterImage
import com.m3u.tv.screens.dashboard.rememberChildPadding
import com.m3u.tv.utils.bringIntoViewIfChildrenAreFocused
@ -109,12 +108,8 @@ private fun ImmersiveList(
ImmersiveListChannelsRow(
channels = channels,
itemDirection = ItemDirection.Horizontal,
title = sectionTitle,
showItemTitle = !isListFocused,
showIndexOverImage = true,
onChannelSelected = onChannelClick,
onChannelFocused = onChannelFocused,
modifier = Modifier.onFocusChanged(onFocusChanged)
)
}
@ -137,7 +132,7 @@ private fun Background(
targetState = channel,
label = "posterUriCrossfade",
) {
) {
PosterImage(channel = it, modifier = Modifier.fillMaxSize())
}
}

View File

@ -1,24 +1,25 @@
package com.m3u.tv.screens.search
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.m3u.core.util.basic.title
import com.m3u.data.database.model.Channel
import com.m3u.i18n.R
import com.m3u.tv.common.ChannelsRow
@ -39,64 +40,49 @@ fun SearchScreen(
}
}
val searchState by searchScreenViewModel.searchState.collectAsStateWithLifecycle()
val channels = searchScreenViewModel.channels.collectAsLazyPagingItems()
LaunchedEffect(shouldShowTopBar) {
onScroll(shouldShowTopBar)
}
when (val s = searchState) {
is SearchState.Searching -> {
Text(text = "Searching...")
}
is SearchState.Done -> {
val channels = s.channels
SearchResult(
channels = channels,
searchChannels = searchScreenViewModel::query,
onChannelClick = onChannelClick,
modifier = Modifier.fillMaxSize()
)
}
}
SearchResult(
searchQuery = searchScreenViewModel.searchQuery,
channels = channels,
onChannelClick = onChannelClick,
modifier = Modifier.fillMaxSize()
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchResult(
channels: List<Channel>,
searchChannels: (queryString: String) -> Unit,
searchQuery: MutableState<String>,
channels: LazyPagingItems<Channel>,
onChannelClick: (channel: Channel) -> Unit,
modifier: Modifier = Modifier,
lazyColumnState: LazyListState = rememberLazyListState(),
) {
val childPadding = rememberChildPadding()
var searchQuery by remember { mutableStateOf("") }
LazyColumn(
Column(
modifier = modifier,
state = lazyColumnState
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = stringResource(R.string.feat_setting_placeholder_title),
keyboardActions = KeyboardActions(
onSearch = { searchChannels(searchQuery) }
),
modifier = Modifier.padding(start = childPadding.start)
)
}
TextField(
value = searchQuery.value,
onValueChange = { searchQuery.value = it },
placeholder = stringResource(R.string.feat_setting_placeholder_title).title(),
modifier = Modifier
.padding(
start = childPadding.start,
end = childPadding.end
)
)
item {
ChannelsRow(
modifier = Modifier
.fillMaxSize()
.padding(top = childPadding.top * 2),
channels = channels
) { selectedChannel -> onChannelClick(selectedChannel) }
}
ChannelsRow(
channels = channels,
onChannelSelected = { selectedChannel -> onChannelClick(selectedChannel) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
}

View File

@ -1,41 +1,40 @@
package com.m3u.tv.screens.search
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.m3u.data.database.model.Channel
import com.m3u.data.repository.channel.ChannelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import javax.inject.Inject
@HiltViewModel
class SearchScreenViewModel @Inject constructor(
private val channelRepository: ChannelRepository
) : ViewModel() {
val channels: Flow<PagingData<Channel>> = snapshotFlow { searchQuery.value }
.flatMapLatest { query ->
if (query.isBlank()) {
emptyFlow()
} else {
Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
prefetchDistance = 5
),
pagingSourceFactory = { channelRepository.search(query) }
)
.flow
}
}
private val internalSearchState = MutableSharedFlow<SearchState>()
fun query(queryString: String) {
viewModelScope.launch { postQuery(queryString) }
}
private suspend fun postQuery(queryString: String) {
internalSearchState.emit(SearchState.Searching)
// val result = channelRepository.searchChannels(query = queryString)
// internalSearchState.emit(SearchState.Done(result))
}
val searchState = internalSearchState.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchState.Done(emptyList())
)
var searchQuery = mutableStateOf("")
}
sealed interface SearchState {
data object Searching : SearchState
data class Done(val channels: List<Channel>) : SearchState
}

View File

@ -240,4 +240,14 @@ internal interface ChannelDao {
category: String,
): Flow<AdjacentChannels>
@Query(
"""
SELECT * FROM streams WHERE 1
AND title LIKE '%'||:query||'%'
"""
)
fun query(
query: String
): PagingSource<Int, Channel>
}

View File

@ -1,5 +1,6 @@
package com.m3u.data.repository.channel
import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.m3u.core.wrapper.Sort
import com.m3u.data.database.model.AdjacentChannels
@ -36,4 +37,5 @@ interface ChannelRepository {
fun observeAllUnseenFavourites(limit: Duration): Flow<List<Channel>>
fun observeAllFavourite(): Flow<List<Channel>>
fun observeAllHidden(): Flow<List<Channel>>
fun search(query: String): PagingSource<Int, Channel>
}

View File

@ -1,5 +1,6 @@
package com.m3u.data.repository.channel
import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.Profiles
@ -107,4 +108,8 @@ internal class ChannelRepositoryImpl @Inject constructor(
override fun observeAllHidden(): Flow<List<Channel>> = channelDao.observeAllHidden()
.catch { emit(emptyList()) }
override fun search(query: String): PagingSource<Int, Channel> {
return channelDao.query(query)
}
}