This commit is contained in:
Lsong 2024-08-29 17:09:26 +08:00
parent 2c59a4e06d
commit 27e07dae05
7 changed files with 155 additions and 207 deletions

View File

@ -2,7 +2,7 @@ package me.lsong.mytv.epg
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import me.lsong.mytv.iptv.TVChannel import me.lsong.mytv.providers.TVChannel
import me.lsong.mytv.epg.EpgChannel.Companion.currentProgrammes import me.lsong.mytv.epg.EpgChannel.Companion.currentProgrammes
import me.lsong.mytv.epg.EpgProgramme.Companion.isLive import me.lsong.mytv.epg.EpgProgramme.Companion.isLive

View File

@ -1,4 +1,4 @@
package me.lsong.mytv.iptv package me.lsong.mytv.providers
import android.util.Log import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable

View File

@ -7,12 +7,19 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -38,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -46,16 +54,16 @@ import me.lsong.mytv.R
import me.lsong.mytv.epg.EpgList import me.lsong.mytv.epg.EpgList
import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes
import me.lsong.mytv.epg.EpgRepository import me.lsong.mytv.epg.EpgRepository
import me.lsong.mytv.iptv.IPTVProvider import me.lsong.mytv.providers.IPTVProvider
import me.lsong.mytv.iptv.TVChannel import me.lsong.mytv.providers.TVChannel
import me.lsong.mytv.iptv.TVGroupList import me.lsong.mytv.providers.TVGroupList
import me.lsong.mytv.iptv.TVGroupList.Companion.channels import me.lsong.mytv.providers.TVGroupList.Companion.channels
import me.lsong.mytv.iptv.TVGroupList.Companion.findGroupIndex import me.lsong.mytv.providers.TVGroupList.Companion.findGroupIndex
import me.lsong.mytv.iptv.TVProvider import me.lsong.mytv.providers.TVProvider
import me.lsong.mytv.ui.components.LeanbackVisible import me.lsong.mytv.ui.components.LeanbackVisible
import me.lsong.mytv.ui.components.MonitorScreen import me.lsong.mytv.ui.components.MonitorScreen
import me.lsong.mytv.ui.components.MyTvMenu
import me.lsong.mytv.ui.components.MyTvMenuItem import me.lsong.mytv.ui.components.MyTvMenuItem
import me.lsong.mytv.ui.components.MyTvMenuItemList
import me.lsong.mytv.ui.components.MyTvNowPlaying import me.lsong.mytv.ui.components.MyTvNowPlaying
import me.lsong.mytv.ui.player.MyTvVideoScreen import me.lsong.mytv.ui.player.MyTvVideoScreen
import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState
@ -143,7 +151,7 @@ fun MyTvMenuWidget(
channelProvider: () -> TVChannel = { TVChannel() }, channelProvider: () -> TVChannel = { TVChannel() },
groupListProvider: () -> TVGroupList = { TVGroupList() }, groupListProvider: () -> TVGroupList = { TVGroupList() },
onSelected: (TVChannel) -> Unit = {}, onSelected: (TVChannel) -> Unit = {},
onSettings: () -> Unit = {}, onSettings: (() -> Unit)? = null,
onUserAction: () -> Unit = {} onUserAction: () -> Unit = {}
) { ) {
val groupList = groupListProvider() val groupList = groupListProvider()
@ -179,21 +187,64 @@ fun MyTvMenuWidget(
} ?: emptyList() } ?: emptyList()
} }
Row { var focusedGroup by remember { mutableStateOf(currentGroup) }
MyTvMenu( var focusedItem by remember { mutableStateOf(currentMenuItem) }
groups = groups, var items by remember { mutableStateOf(itemsProvider(focusedGroup.title)) }
itemsProvider = itemsProvider, val rightListFocusRequester = remember { FocusRequester() }
currentGroup = currentGroup,
currentItem = currentMenuItem, Row(modifier = modifier) {
onItemSelected = { selectedItem -> Column (
val selectedChannel = groupList.channels.first { it.title == selectedItem.title } verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.width(250.dp)
.fillMaxHeight(),
) {
MyTvMenuItemList(
items = groups,
selectedItem = focusedGroup,
onFocused = { menuItem ->
focusedGroup = menuItem
items = itemsProvider(menuItem.title)
},
onSelected = { menuItem ->
focusedGroup = menuItem
items = itemsProvider(menuItem.title)
focusedItem = items.firstOrNull() ?: MyTvMenuItem()
rightListFocusRequester.requestFocus()
},
onUserAction = onUserAction,
modifier = Modifier.weight(1f)
)
LeanbackVisible ({ onSettings != null }) {
TvLazyColumn(
modifier = Modifier.width(250.dp),
contentPadding = PaddingValues(8.dp),
) {
item {
MyTvMenuItem(
item = MyTvMenuItem(icon = Icons.Default.Settings, title = "Settings"),
onSelected = onSettings!!
)
}
}
}
}
MyTvMenuItemList(
items = items,
selectedItem = focusedItem,
onSelected = { menuItem ->
focusedItem = menuItem
val selectedChannel = groupList.channels.first { it.title == menuItem.title }
onSelected(selectedChannel) onSelected(selectedChannel)
}, },
modifier = modifier,
onSettings = onSettings,
onUserAction = onUserAction, onUserAction = onUserAction,
focusRequester = rightListFocusRequester
) )
} }
LaunchedEffect(Unit) {
rightListFocusRequester.requestFocus()
}
} }
@Composable @Composable

View File

@ -10,13 +10,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.lsong.mytv.iptv.TVChannel import me.lsong.mytv.providers.TVChannel
import me.lsong.mytv.iptv.TVGroupList import me.lsong.mytv.providers.TVGroupList
import me.lsong.mytv.iptv.TVGroupList.Companion.channels import me.lsong.mytv.providers.TVGroupList.Companion.channels
import me.lsong.mytv.iptv.TVGroupList.Companion.findChannelIndex import me.lsong.mytv.providers.TVGroupList.Companion.findChannelIndex
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.ui.player.LeanbackVideoPlayerState import me.lsong.mytv.ui.player.LeanbackVideoPlayerState
import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState
import me.lsong.mytv.utils.Settings import me.lsong.mytv.utils.Settings

View File

@ -17,7 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.lsong.mytv.iptv.TVChannel import me.lsong.mytv.providers.TVChannel
import me.lsong.mytv.ui.theme.LeanbackTheme import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.isIPv6 import me.lsong.mytv.utils.isIPv6

View File

@ -1,30 +1,19 @@
package me.lsong.mytv.ui.components package me.lsong.mytv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@ -59,7 +48,7 @@ fun MyTvMenuItem(
isSelected: Boolean = false, isSelected: Boolean = false,
onFocused: () -> Unit = {}, onFocused: () -> Unit = {},
onSelected: () -> Unit = {}, onSelected: () -> Unit = {},
onFavoriteToggle: () -> Unit = {}, onLongSelect: () -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
LaunchedEffect(isSelected) { LaunchedEffect(isSelected) {
@ -71,130 +60,67 @@ fun MyTvMenuItem(
LocalContentColor provides if (isFocused) MaterialTheme.colorScheme.background LocalContentColor provides if (isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onBackground
) { ) {
Box( androidx.tv.material3.ListItem(
modifier = Modifier.clip(ListItemDefaults.shape().shape), modifier = modifier
) { .focusRequester(focusRequester)
androidx.tv.material3.ListItem( .onFocusChanged { if (it.isFocused) onFocused() }
modifier = modifier .handleLeanbackKeyEvents(
.align(Alignment.Center) key = item.hashCode(),
.focusRequester(focusRequester) onSelect = onSelected,
.onFocusChanged { if (it.isFocused) onFocused() } onLongSelect = onLongSelect,
.align(Alignment.Center)
.handleLeanbackKeyEvents(
key = item.hashCode(),
onSelect = onSelected,
onLongSelect = onFavoriteToggle,
),
colors = ListItemDefaults.colors(
focusedContentColor = MaterialTheme.colorScheme.background,
focusedContainerColor = MaterialTheme.colorScheme.onBackground,
selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
), ),
onClick = onSelected, colors = ListItemDefaults.colors(
selected = isSelected, focusedContentColor = MaterialTheme.colorScheme.background,
leadingContent = item.icon?.let { icon -> focusedContainerColor = MaterialTheme.colorScheme.onBackground,
{ selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
when (icon) { ),
is ImageVector -> Icon( onClick = onSelected,
imageVector = icon, selected = isSelected,
contentDescription = item.title, leadingContent = item.icon?.let { icon ->
modifier = Modifier.size(24.dp) {
) when (icon) {
is String -> if (icon.isEmpty()) { is ImageVector -> Icon(
Text( imageVector = icon,
modifier = Modifier contentDescription = item.title,
.size(40.dp) modifier = Modifier.size(24.dp)
.background(color = MaterialTheme.colorScheme.primary)
.wrapContentHeight(align = Alignment.CenterVertically),
textAlign = TextAlign.Center,
text = item.title.take(2).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
AsyncImage(
model = icon,
contentDescription = item.title,
modifier = Modifier.size(40.dp)
)
}
else -> null
}
}
},
headlineContent = { Text(text = item.title, maxLines = 2) },
supportingContent = item.description?.let {
{
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
modifier = Modifier.alpha(0.8f),
) )
is String -> if (icon.isEmpty()) {
Text(
modifier = Modifier
.size(40.dp)
.background(color = MaterialTheme.colorScheme.primary)
.wrapContentHeight(align = Alignment.CenterVertically),
textAlign = TextAlign.Center,
text = item.title.take(2).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
AsyncImage(
model = icon,
contentDescription = item.title,
modifier = Modifier.size(40.dp)
)
}
else -> null
} }
}, }
) },
} headlineContent = { Text(text = item.title, maxLines = 2) },
supportingContent = item.description?.let {
{
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
modifier = Modifier.alpha(0.8f),
)
}
},
)
} }
} }
@Composable
fun MyTvMenu(
groups: List<MyTvMenuItem>,
itemsProvider: (String) -> List<MyTvMenuItem>,
currentGroup: MyTvMenuItem,
currentItem: MyTvMenuItem,
onGroupFocused: (MyTvMenuItem) -> Unit = {},
onGroupSelected: (MyTvMenuItem) -> Unit = {},
onItemSelected: (MyTvMenuItem) -> Unit = {},
modifier: Modifier = Modifier,
onUserAction: () -> Unit = {},
onSettings: () -> Unit = {}
) {
var focusedGroup by remember { mutableStateOf(currentGroup) }
var focusedItem by remember { mutableStateOf(currentItem) }
var items by remember { mutableStateOf(itemsProvider(focusedGroup.title)) }
val rightListFocusRequester = remember { FocusRequester() }
Row(modifier = modifier) {
MyTvMenuItemList(
items = groups,
onSettings = onSettings,
selectedItem = focusedGroup,
onFocused = { menuGroupItem ->
focusedGroup = menuGroupItem
items = itemsProvider(menuGroupItem.title)
onGroupFocused(focusedGroup)
},
onSelected = { menuGroupItem ->
focusedGroup = menuGroupItem
items = itemsProvider(menuGroupItem.title)
focusedItem = items.firstOrNull() ?: MyTvMenuItem()
onGroupSelected(focusedGroup)
rightListFocusRequester.requestFocus()
},
onUserAction = onUserAction
)
MyTvMenuItemList(
items = items,
selectedItem = focusedItem,
onSelected = { menuItem ->
focusedItem = menuItem
onItemSelected(focusedItem)
},
onUserAction = onUserAction,
focusRequester = rightListFocusRequester
)
}
LaunchedEffect(Unit) {
rightListFocusRequester.requestFocus()
}
}
@Composable @Composable
fun MyTvMenuItemList( fun MyTvMenuItemList(
items: List<MyTvMenuItem>, items: List<MyTvMenuItem>,
@ -202,15 +128,12 @@ fun MyTvMenuItemList(
onUserAction: () -> Unit = {}, onUserAction: () -> Unit = {},
onFocused: (MyTvMenuItem) -> Unit = {}, onFocused: (MyTvMenuItem) -> Unit = {},
onSelected: (MyTvMenuItem) -> Unit = {}, onSelected: (MyTvMenuItem) -> Unit = {},
onFavoriteToggle: (MyTvMenuItem) -> Unit = {}, onLongSelect: (MyTvMenuItem) -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSettings: (() -> Unit)? = null,
) { ) {
var focusedItem by remember { mutableStateOf(selectedItem) }
val selectedIndex = remember(selectedItem, items) { items.indexOf(selectedItem) } val selectedIndex = remember(selectedItem, items) { items.indexOf(selectedItem) }
val itemFocusRequesterList = remember(items) { List(items.size) { FocusRequester() } } val itemFocusRequesterList = remember(items) { List(items.size) { FocusRequester() } }
val settingsFocusRequester = remember { FocusRequester() }
val listState = rememberTvLazyListState() val listState = rememberTvLazyListState()
LaunchedEffect(listState) { LaunchedEffect(listState) {
@ -224,50 +147,26 @@ fun MyTvMenuItemList(
listState.scrollToItem(maxOf(0, index)) listState.scrollToItem(maxOf(0, index))
} }
Column( TvLazyColumn(
state = listState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier modifier = modifier
.fillMaxHeight()
.width(250.dp) .width(250.dp)
.background(MaterialTheme.colorScheme.background.copy(0.8f)) .background(MaterialTheme.colorScheme.background.copy(0.8f))
.focusRequester(focusRequester) .focusRequester(focusRequester)
) { ) {
TvLazyColumn( itemsIndexed(items, key = { _, item -> item.hashCode() }) { index, item ->
state = listState, MyTvMenuItem(
contentPadding = PaddingValues(8.dp), item = item,
verticalArrangement = Arrangement.spacedBy(8.dp), isFocused = selectedIndex == index,
modifier = Modifier.weight(1f).align(Alignment.CenterHorizontally) isSelected = selectedIndex == index,
) { onFocused = { onFocused(item) },
itemsIndexed(items, key = { _, item -> item.hashCode() }) { index, item -> onSelected = { onSelected(item) },
MyTvMenuItem( onLongSelect = { onLongSelect(item) },
item = item, focusRequester = itemFocusRequesterList[index],
focusRequester = itemFocusRequesterList[index], )
isSelected = selectedIndex == index,
isFocused = selectedIndex == index,
onSelected = { onSelected(item) },
onFocused = {
focusedItem = item
onFocused(item)
},
onFavoriteToggle = { onFavoriteToggle(item) }
)
}
} }
// Settings button at the bottom
if (onSettings != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(8.dp)
) {
MyTvMenuItem(
item = MyTvMenuItem(icon = Icons.Default.Settings, title = "Settings"),
focusRequester = settingsFocusRequester,
onSelected = onSettings
)
}
}
} }
} }

View File

@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import me.lsong.mytv.rememberLeanbackChildPadding import me.lsong.mytv.rememberLeanbackChildPadding
import me.lsong.mytv.epg.EpgList import me.lsong.mytv.epg.EpgList
import me.lsong.mytv.epg.EpgList.Companion.getEpgChannel import me.lsong.mytv.epg.EpgList.Companion.getEpgChannel
import me.lsong.mytv.iptv.TVChannel import me.lsong.mytv.providers.TVChannel
import me.lsong.mytv.ui.player.LeanbackVideoPlayer import me.lsong.mytv.ui.player.LeanbackVideoPlayer
import me.lsong.mytv.ui.theme.LeanbackTheme import me.lsong.mytv.ui.theme.LeanbackTheme
@ -56,13 +56,13 @@ fun MyTvNowPlaying(
channelSourceIndexProvider = sourceIndexProvider, channelSourceIndexProvider = sourceIndexProvider,
) )
val epg = epgListProvider().getEpgChannel(channelProvider()) // val epg = epgListProvider().getEpgChannel(channelProvider())
if (epg != null) { // if (epg != null) {
MyTvEpgView( // MyTvEpgView(
modifier = modifier, // modifier = modifier,
epgProvider = { epg }, // epgProvider = { epg },
) // )
} // }
MyTvPlayerInfo( MyTvPlayerInfo(
modifier = modifier.padding(start = childPadding.start, bottom = childPadding.bottom), modifier = modifier.padding(start = childPadding.start, bottom = childPadding.bottom),
metadataProvider = videoPlayerMetadataProvider metadataProvider = videoPlayerMetadataProvider