diff --git a/androidApp/src/main/java/com/m3u/androidApp/ui/AppRootGraph.kt b/androidApp/src/main/java/com/m3u/androidApp/ui/AppRootGraph.kt index 72cc4b25c..3963402c3 100644 --- a/androidApp/src/main/java/com/m3u/androidApp/ui/AppRootGraph.kt +++ b/androidApp/src/main/java/com/m3u/androidApp/ui/AppRootGraph.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -24,6 +25,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -95,12 +97,17 @@ internal fun AppRootGraph( } }, actions = { - actions.forEach { action -> - IconButton( - icon = action.icon, - contentDescription = action.contentDescription, - onClick = action.onClick - ) + + CompositionLocalProvider( + androidx.tv.material3.LocalContentColor provides LocalContentColor.current + ) { + actions.forEach { action -> + IconButton( + icon = action.icon, + contentDescription = action.contentDescription, + onClick = action.onClick + ) + } } }, modifier = Modifier.fillMaxWidth() diff --git a/data/src/main/java/com/m3u/data/service/impl/MessageServiceImpl.kt b/data/src/main/java/com/m3u/data/service/impl/MessageServiceImpl.kt index 9eb949ab8..76e39b3d1 100644 --- a/data/src/main/java/com/m3u/data/service/impl/MessageServiceImpl.kt +++ b/data/src/main/java/com/m3u/data/service/impl/MessageServiceImpl.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -18,17 +19,20 @@ class MessageServiceImpl @Inject constructor() : MessageService { private val _message: MutableStateFlow = MutableStateFlow(Message.Dynamic.EMPTY) override val message: StateFlow get() = _message.asStateFlow() - private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() private val job = AtomicReference() override fun emit(message: Message) { - job.getAndUpdate { prev -> - prev?.cancel() - coroutineScope.launch { - _message.update { message } - delay(message.duration) - _message.update { Message.Dynamic.EMPTY } + coroutineScope.launch { + job.getAndUpdate { prev -> + prev?.cancel() + coroutineScope.launch { + _message.update { message } + delay(message.duration) + _message.update { Message.Dynamic.EMPTY } + } } } + } } diff --git a/features/about/src/main/java/com/m3u/features/about/AboutScreen.kt b/features/about/src/main/java/com/m3u/features/about/AboutScreen.kt index 038bb9b4b..60361271b 100644 --- a/features/about/src/main/java/com/m3u/features/about/AboutScreen.kt +++ b/features/about/src/main/java/com/m3u/features/about/AboutScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.m3u.core.util.basic.title import com.m3u.features.about.components.ContributorItem @@ -26,7 +27,6 @@ import com.m3u.material.ktx.plus import com.m3u.material.model.LocalSpacing import com.m3u.ui.LocalHelper import com.m3u.ui.MonoText -import com.m3u.ui.repeatOnLifecycle @Composable internal fun AboutRoute( @@ -36,8 +36,9 @@ internal fun AboutRoute( ) { val helper = LocalHelper.current val title = stringResource(string.feat_about_title) - helper.repeatOnLifecycle { - this.title = title.title() + LifecycleResumeEffect(Unit) { + helper.title = title.title() + onPauseOrDispose { } } val state by viewModel.s.collectAsStateWithLifecycle() diff --git a/features/console/src/main/java/com/m3u/features/console/ConsoleScreen.kt b/features/console/src/main/java/com/m3u/features/console/ConsoleScreen.kt index 22b954857..e7bb04693 100644 --- a/features/console/src/main/java/com/m3u/features/console/ConsoleScreen.kt +++ b/features/console/src/main/java/com/m3u/features/console/ConsoleScreen.kt @@ -23,7 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.m3u.core.util.basic.title import com.m3u.i18n.R.string import com.m3u.material.components.Background import com.m3u.material.components.TextField @@ -31,7 +33,6 @@ import com.m3u.material.ktx.plus import com.m3u.material.model.LocalSpacing import com.m3u.ui.LocalHelper import com.m3u.ui.MonoText -import com.m3u.ui.repeatOnLifecycle @Composable internal fun ConsoleRoute( @@ -41,8 +42,9 @@ internal fun ConsoleRoute( ) { val helper = LocalHelper.current val title = stringResource(string.feat_console_title) - helper.repeatOnLifecycle { - this.title = title + LifecycleResumeEffect(Unit) { + helper.title = title.title() + onPauseOrDispose { } } val state by viewModel.state.collectAsStateWithLifecycle() ConsoleScreen( diff --git a/features/favorite/src/main/java/com/m3u/features/favorite/components/FavoriteGallery.kt b/features/favorite/src/main/java/com/m3u/features/favorite/components/FavoriteGallery.kt index 704856e26..e6cc5269b 100644 --- a/features/favorite/src/main/java/com/m3u/features/favorite/components/FavoriteGallery.kt +++ b/features/favorite/src/main/java/com/m3u/features/favorite/components/FavoriteGallery.kt @@ -74,7 +74,7 @@ private fun FavouriteGalleryImpl( LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(rowCount), verticalItemSpacing = spacing.medium, - horizontalArrangement = Arrangement.spacedBy(spacing.medium), + horizontalArrangement = Arrangement.spacedBy(spacing.large), contentPadding = PaddingValues(spacing.medium) + contentPadding, modifier = modifier.fillMaxSize(), ) { diff --git a/features/foryou/src/main/java/com/m3u/features/foryou/components/PlaylistGallery.kt b/features/foryou/src/main/java/com/m3u/features/foryou/components/PlaylistGallery.kt index 581e08bcf..92ad34604 100644 --- a/features/foryou/src/main/java/com/m3u/features/foryou/components/PlaylistGallery.kt +++ b/features/foryou/src/main/java/com/m3u/features/foryou/components/PlaylistGallery.kt @@ -97,7 +97,7 @@ private fun PlaylistGalleryImpl( horizontal = spacing.large ) + contentPadding, verticalArrangement = Arrangement.spacedBy(spacing.medium), - horizontalArrangement = Arrangement.spacedBy(spacing.medium), + horizontalArrangement = Arrangement.spacedBy(spacing.large), modifier = modifier.fillMaxSize() ) { items( diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistScreen.kt b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistScreen.kt index d9a30454f..d71246271 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistScreen.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistScreen.kt @@ -34,6 +34,7 @@ import com.google.accompanist.permissions.rememberPermissionState import com.m3u.core.architecture.pref.LocalPref import com.m3u.core.util.compose.observableStateOf import com.m3u.core.wrapper.Event +import com.m3u.core.wrapper.Message import com.m3u.data.database.model.Stream import com.m3u.features.playlist.impl.PlaylistScreenImpl import com.m3u.features.playlist.impl.TvPlaylistScreenImpl @@ -70,6 +71,8 @@ internal fun PlaylistRoute( val sorts = viewModel.sorts val sort by viewModel.sort.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + // If you try to check or request the WRITE_EXTERNAL_STORAGE on Android 13+, // it will always return false. @@ -107,6 +110,7 @@ internal fun PlaylistRoute( Background { PlaylistScreen( title = playlist?.title.orEmpty(), + message = message, query = query, onQuery = { viewModel.onEvent(PlaylistEvent.Query(it)) }, rowCount = pref.rowCount, @@ -142,6 +146,7 @@ internal fun PlaylistRoute( private fun PlaylistScreen( title: String, query: String, + message: Message, onQuery: (String) -> Unit, rowCount: Int, zapping: Stream?, @@ -202,6 +207,7 @@ private fun PlaylistScreen( } else { TvPlaylistScreenImpl( title = title, + message = message, channels = channels, query = query, onQuery = onQuery, diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt index 825e46c58..37ef6f1e4 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/PlaylistViewModel.kt @@ -84,7 +84,7 @@ class PlaylistViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000) ) - val zapping: StateFlow = combine( + internal val zapping: StateFlow = combine( zappingMode, playerService.url, streamRepository.observeAll() @@ -99,7 +99,7 @@ class PlaylistViewModel @Inject constructor( ) private var _refreshing = MutableStateFlow(false) - val refreshing = _refreshing.asStateFlow() + internal val refreshing = _refreshing.asStateFlow() private fun refresh() { val url = playlistUrl.value @@ -201,7 +201,7 @@ class PlaylistViewModel @Inject constructor( } private val _query: MutableStateFlow = MutableStateFlow("") - val query: StateFlow = _query.asStateFlow() + internal val query: StateFlow = _query.asStateFlow() private fun query(event: PlaylistEvent.Query) { val text = event.text _query.update { text } @@ -240,11 +240,11 @@ class PlaylistViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L) ) - val sorts: ImmutableList = Sort.entries.toPersistentList() + internal val sorts: ImmutableList = Sort.entries.toPersistentList() private val sortIndex: MutableStateFlow = MutableStateFlow(0) - val sort: StateFlow = sortIndex + internal val sort: StateFlow = sortIndex .map { sorts[it] } .stateIn( scope = viewModelScope, @@ -252,11 +252,11 @@ class PlaylistViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L) ) - fun sort(sort: Sort) { + internal fun sort(sort: Sort) { sortIndex.update { sorts.indexOf(sort).coerceAtLeast(0) } } - val channels: StateFlow> = combine( + internal val channels: StateFlow> = combine( unsorted, sort, recommend @@ -273,4 +273,6 @@ class PlaylistViewModel @Inject constructor( initialValue = persistentListOf(), started = SharingStarted.WhileSubscribed(5_000L) ) + + internal val message = messageService.message } \ No newline at end of file diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt b/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt index 1d8cdb041..5fd5874b0 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/TvPlaylistActivity.kt @@ -13,18 +13,12 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.m3u.core.Contracts import com.m3u.core.architecture.logger.Logger @@ -37,10 +31,8 @@ import com.m3u.core.util.context.isPortraitMode import com.m3u.core.wrapper.Message import com.m3u.data.service.MessageService import com.m3u.data.service.PlayerService -import com.m3u.material.model.LocalSpacing import com.m3u.ui.Action import com.m3u.ui.AppLocalProvider -import com.m3u.ui.AppSnackHost import com.m3u.ui.Fob import com.m3u.ui.Helper import com.m3u.ui.OnPipModeChanged @@ -84,40 +76,29 @@ class TvPlaylistActivity : AppCompatActivity() { helper = helper, pref = pref ) { - val spacing = LocalSpacing.current - val message by messageService.message.collectAsStateWithLifecycle() - val darkMode = pref.darkMode LaunchedEffect(darkMode) { helper.darkMode = darkMode.unspecifiable } - Box { - PlaylistRoute( - navigateToStream = { - val options = ActivityOptions.makeCustomAnimation( - this@TvPlaylistActivity, - 0, - 0 - ) - startActivity( - Intent().apply { - component = ComponentName.createRelative( - this@TvPlaylistActivity, - Contracts.PLAYER_ACTIVITY - ) - }, - options.toBundle() - ) - } - ) - AppSnackHost( - message = message, - modifier = Modifier - .align(Alignment.TopStart) - .padding(spacing.medium) - ) - } + PlaylistRoute( + navigateToStream = { + val options = ActivityOptions.makeCustomAnimation( + this@TvPlaylistActivity, + 0, + 0 + ) + startActivity( + Intent().apply { + component = ComponentName.createRelative( + this@TvPlaylistActivity, + Contracts.PLAYER_ACTIVITY + ) + }, + options.toBundle() + ) + } + ) } } } diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/impl/PlaylistScreenImpl.kt b/features/playlist/src/main/java/com/m3u/features/playlist/impl/PlaylistScreenImpl.kt index ea03917f9..94c23af22 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/impl/PlaylistScreenImpl.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/impl/PlaylistScreenImpl.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import com.m3u.core.wrapper.Event import com.m3u.data.database.model.Stream import com.m3u.features.playlist.Channel @@ -58,7 +59,6 @@ import com.m3u.ui.EventHandler import com.m3u.ui.LocalHelper import com.m3u.ui.Sort import com.m3u.ui.SortBottomSheet -import com.m3u.ui.repeatOnLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @@ -111,9 +111,8 @@ internal fun PlaylistScreenImpl( var dialogStatus: DialogStatus by remember { mutableStateOf(DialogStatus.Idle) } var isSortSheetVisible by rememberSaveable { mutableStateOf(false) } - - helper.repeatOnLifecycle { - actions = persistentListOf( + LifecycleResumeEffect(Unit) { + helper.actions = persistentListOf( Action( icon = Icons.AutoMirrored.Rounded.Sort, contentDescription = "sort", @@ -125,6 +124,7 @@ internal fun PlaylistScreenImpl( onClick = onRefresh ) ) + onPauseOrDispose { } } Background { diff --git a/features/playlist/src/main/java/com/m3u/features/playlist/impl/TvPlaylistScreenImpl.kt b/features/playlist/src/main/java/com/m3u/features/playlist/impl/TvPlaylistScreenImpl.kt index a5bc8e0cb..cf4689cf4 100644 --- a/features/playlist/src/main/java/com/m3u/features/playlist/impl/TvPlaylistScreenImpl.kt +++ b/features/playlist/src/main/java/com/m3u/features/playlist/impl/TvPlaylistScreenImpl.kt @@ -54,6 +54,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.rememberDrawerState import coil.compose.AsyncImage import coil.request.ImageRequest +import com.m3u.core.wrapper.Message import com.m3u.data.database.model.Stream import com.m3u.features.playlist.Channel import com.m3u.features.playlist.R @@ -63,6 +64,7 @@ import com.m3u.material.components.IconButton import com.m3u.material.ktx.Edge import com.m3u.material.ktx.blurEdge import com.m3u.material.model.LocalSpacing +import com.m3u.ui.AppSnackHost import com.m3u.ui.LocalHelper import com.m3u.ui.Sort import kotlinx.collections.immutable.ImmutableList @@ -70,6 +72,7 @@ import kotlinx.collections.immutable.ImmutableList @Composable internal fun TvPlaylistScreenImpl( title: String, + message: Message, channels: ImmutableList, findStreamById: (Int) -> Stream?, query: String, @@ -255,29 +258,34 @@ internal fun TvPlaylistScreenImpl( } } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.small), - modifier = Modifier - .align(Alignment.TopEnd) - .padding(spacing.medium) + Column( + Modifier.padding(spacing.medium), + verticalArrangement = Arrangement.spacedBy(spacing.medium) ) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.ExtraBold, - maxLines = 1 - ) - Spacer(modifier = Modifier.weight(1f)) - IconButton( - icon = Icons.Rounded.Search, - contentDescription = "search", - onClick = { /*TODO*/ } - ) - IconButton( - icon = Icons.Rounded.Refresh, - contentDescription = "refresh", - onClick = onRefresh + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.small), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + maxLines = 1 + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + icon = Icons.Rounded.Search, + contentDescription = "search", + onClick = { /*TODO*/ } + ) + IconButton( + icon = Icons.Rounded.Refresh, + contentDescription = "refresh", + onClick = onRefresh + ) + } + AppSnackHost( + message = message, ) } } diff --git a/features/setting/src/main/java/com/m3u/features/setting/components/ClipboardButton.kt b/features/setting/src/main/java/com/m3u/features/setting/components/ClipboardButton.kt index 1a09c1d75..206493122 100644 --- a/features/setting/src/main/java/com/m3u/features/setting/components/ClipboardButton.kt +++ b/features/setting/src/main/java/com/m3u/features/setting/components/ClipboardButton.kt @@ -20,7 +20,7 @@ internal fun ClipboardButton( enabled = enabled, text = stringResource(string.feat_setting_label_parse_from_clipboard), onClick = { - val clipboardUrl = clipboardManager.getText()?.text.orEmpty() + val clipboardUrl = clipboardManager.getText()?.text?.trim()?: return@TextButton val clipboardTitle = run { val filePath = clipboardUrl.split("/") val fileSplit = filePath.lastOrNull()?.split(".") ?: emptyList() diff --git a/features/setting/src/main/java/com/m3u/features/setting/fragments/preferences/PreferencesFragment.kt b/features/setting/src/main/java/com/m3u/features/setting/fragments/preferences/PreferencesFragment.kt index 2955ad0ac..b6dca75b9 100644 --- a/features/setting/src/main/java/com/m3u/features/setting/fragments/preferences/PreferencesFragment.kt +++ b/features/setting/src/main/java/com/m3u/features/setting/fragments/preferences/PreferencesFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyColumn import com.m3u.material.ktx.isTvDevice +import com.m3u.material.model.LocalSpacing import com.m3u.ui.Destination.Root.Setting.SettingFragment @Composable @@ -25,6 +26,7 @@ internal fun PreferencesFragment( navigateToAbout: () -> Unit, modifier: Modifier = Modifier ) { + val spacing = LocalSpacing.current val tv = isTvDevice() if (!tv) { LazyColumn( @@ -65,7 +67,7 @@ internal fun PreferencesFragment( modifier = modifier, contentPadding = contentPadding, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(1.dp) + verticalArrangement = Arrangement.spacedBy(spacing.medium) ) { item { RegularPreferences( @@ -73,18 +75,15 @@ internal fun PreferencesFragment( navigateToPlaylistManagement = navigateToPlaylistManagement, navigateToThemeSelector = navigateToThemeSelector ) - HorizontalDivider() } item { OptionalPreferences() - HorizontalDivider() } item { ExperimentalPreference( navigateToScriptManagement = navigateToScriptManagement, navigateToConsole = navigateToConsole ) - HorizontalDivider() } item { OtherPreferences( @@ -96,8 +95,3 @@ internal fun PreferencesFragment( } } } - -@Composable -internal fun T.ifTvDevice(placement: () -> T): T { - return if (isTvDevice()) placement() else this -} diff --git a/features/stream/src/main/java/com/m3u/features/stream/StreamScreen.kt b/features/stream/src/main/java/com/m3u/features/stream/StreamScreen.kt index f741c5041..c2d42999c 100644 --- a/features/stream/src/main/java/com/m3u/features/stream/StreamScreen.kt +++ b/features/stream/src/main/java/com/m3u/features/stream/StreamScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.m3u.core.architecture.pref.LocalPref @@ -37,7 +38,6 @@ import com.m3u.material.components.mask.rememberMaskState import com.m3u.material.ktx.isTvDevice import com.m3u.ui.LocalHelper import com.m3u.ui.OnPipModeChanged -import com.m3u.ui.repeatOnLifecycle import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -77,17 +77,20 @@ fun StreamRoute( } } - helper.repeatOnLifecycle { - darkMode = true.unspecifiable - statusBarVisibility = false.unspecifiable - navigationBarVisibility = false.unspecifiable - onPipModeChanged = OnPipModeChanged { info -> - isPipMode = info.isInPictureInPictureMode - if (!isPipMode) { - maskState.wake() - isAutoZappingMode = false + LifecycleResumeEffect(Unit) { + with(helper) { + darkMode = true.unspecifiable + statusBarVisibility = false.unspecifiable + navigationBarVisibility = false.unspecifiable + onPipModeChanged = OnPipModeChanged { info -> + isPipMode = info.isInPictureInPictureMode + if (!isPipMode) { + maskState.wake() + isAutoZappingMode = false + } } } + onPauseOrDispose { } } LaunchedEffect(pref.zappingMode, playerState.videoSize) { diff --git a/material/src/main/java/com/m3u/material/components/Badges.kt b/material/src/main/java/com/m3u/material/components/Badges.kt index f91f35ea6..90af006c0 100644 --- a/material/src/main/java/com/m3u/material/components/Badges.kt +++ b/material/src/main/java/com/m3u/material/components/Badges.kt @@ -4,12 +4,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.tv.material3.surfaceColorAtElevation +import com.m3u.material.ktx.isTvDevice import com.m3u.material.model.LocalSpacing @Composable @@ -19,10 +24,16 @@ fun TextBadge( icon: (@Composable () -> Unit)? = null, ) { val spacing = LocalSpacing.current - + val tv = isTvDevice() + val tvColorScheme = androidx.tv.material3.MaterialTheme.colorScheme Card( modifier = modifier, - shape = RoundedCornerShape(spacing.small) + shape = RoundedCornerShape(spacing.small), + colors = CardDefaults.cardColors( + containerColor = if (tv) tvColorScheme.surfaceColorAtElevation(4.dp) + else Color.Unspecified, + contentColor = if (tv) tvColorScheme.onSurface else Color.Unspecified + ) ) { Row( verticalAlignment = Alignment.CenterVertically diff --git a/material/src/main/java/com/m3u/material/components/Preferences.kt b/material/src/main/java/com/m3u/material/components/Preferences.kt index 5e218892c..fea207101 100644 --- a/material/src/main/java/com/m3u/material/components/Preferences.kt +++ b/material/src/main/java/com/m3u/material/components/Preferences.kt @@ -5,9 +5,11 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -23,11 +25,8 @@ import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics @@ -36,6 +35,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import com.m3u.material.ktx.isTvDevice +import androidx.tv.material3.ListItem as TvListItem +import androidx.tv.material3.ListItemDefaults as TvListItemDefaults @Composable fun Preference( @@ -51,7 +53,8 @@ fun Preference( // val configuration = LocalConfiguration.current // val type = configuration.uiMode and Configuration.UI_MODE_TYPE_MASK - var focus by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val focus by interactionSource.collectIsFocusedAsState() TooltipBox( state = rememberTooltipState(), @@ -77,98 +80,99 @@ fun Preference( label = "preference-content-color", animationSpec = spring(stiffness = Spring.StiffnessMediumLow) ) - // if (type != Configuration.UI_MODE_TYPE_TELEVISION) { - ListItem( - headlineContent = { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - supportingContent = { - if (content != null) { + if (!isTvDevice()) { + ListItem( + headlineContent = { Text( - text = content.capitalize(Locale.current), - style = MaterialTheme.typography.bodyMedium, + text = title, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier then if (focus) Modifier.basicMarquee() - else Modifier ) - } - }, - trailingContent = trailing, - leadingContent = icon?.let { - @Composable { - Icon(imageVector = it, contentDescription = null) - } - }, - tonalElevation = LocalAbsoluteTonalElevation.current, - colors = ListItemDefaults.colors( - containerColor = currentContainerColor, - headlineColor = currentContentColor, - leadingIconColor = currentContentColor, - overlineColor = currentContentColor, - supportingColor = currentContentColor, - trailingIconColor = currentContentColor, - ), - shadowElevation = elevation, - modifier = modifier - .semantics(mergeDescendants = true) {} - .clickable(enabled, onClick = onClick) - .fillMaxWidth() - .onFocusChanged { - focus = it.hasFocus - } - .focusable() - ) -// } else { -// TvListItem( -// selected = true, -// headlineContent = { -// Text( -// text = title, -// style = MaterialTheme.typography.titleMedium, -// maxLines = 1, -// overflow = TextOverflow.Ellipsis, -// ) -// }, -// supportingContent = { -// if (content != null) { -// Text( -// text = content.capitalize(Locale.current), -// style = MaterialTheme.typography.bodyMedium, -// maxLines = 1, -// overflow = TextOverflow.Ellipsis, -// modifier = Modifier then if (focus) Modifier.basicMarquee() -// else Modifier -// ) -// } -// }, -// trailingContent = trailing, -// leadingContent = { -// icon?.let { -// Icon(imageVector = it, contentDescription = null) -// } -// }, -// tonalElevation = LocalAbsoluteTonalElevation.current, -// colors = TvListItemDefaults.colors( -// containerColor = currentContainerColor, -// contentColor = currentContentColor, -// ), -// shape = TvListItemDefaults.shape(RectangleShape), -// onClick = onClick, -// modifier = modifier -// .semantics(mergeDescendants = true) {} -// .fillMaxWidth() -// .onFocusChanged { -// focus = it.hasFocus -// } -// .focusable() -// ) -// } + }, + supportingContent = { + if (content != null) { + Text( + text = content.capitalize(Locale.current), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier then if (focus) Modifier.basicMarquee() + else Modifier + ) + } + }, + trailingContent = trailing, + leadingContent = icon?.let { + @Composable { + Icon(imageVector = it, contentDescription = null) + } + }, + tonalElevation = LocalAbsoluteTonalElevation.current, + colors = ListItemDefaults.colors( + containerColor = currentContainerColor, + headlineColor = currentContentColor, + leadingIconColor = currentContentColor, + overlineColor = currentContentColor, + supportingColor = currentContentColor, + trailingIconColor = currentContentColor, + ), + shadowElevation = elevation, + modifier = modifier + .semantics(mergeDescendants = true) {} + .clickable( + enabled = enabled, + onClick = onClick, + interactionSource = interactionSource, + indication = rememberRipple() + ) + .fillMaxWidth() + ) + } else { + TvListItem( + selected = focus, + interactionSource = interactionSource, + headlineContent = { + androidx.tv.material3.Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + if (content != null) { + androidx.tv.material3.Text( + text = content.capitalize(Locale.current), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier then if (focus) Modifier.basicMarquee() + else Modifier + ) + } + }, + trailingContent = trailing, + leadingContent = { + icon?.let { + androidx.tv.material3.Icon(imageVector = it, contentDescription = null) + } + }, + tonalElevation = LocalAbsoluteTonalElevation.current, + colors = TvListItemDefaults.colors( + containerColor = currentContainerColor, + contentColor = currentContentColor, + ), + scale = TvListItemDefaults.scale( + scale = 0.9f, + focusedScale = 1f + ), + onClick = onClick, + modifier = modifier + .semantics(mergeDescendants = true) {} + .fillMaxWidth() + ) + } } } @@ -184,14 +188,6 @@ fun CheckBoxPreference( enabled: Boolean = true, icon: ImageVector? = null, ) { - val combined = Modifier - .toggleable( - value = checked, - onValueChange = { onChanged(it) }, - role = Role.Checkbox, - enabled = enabled - ) - .then(modifier) Preference( title = title, content = content, @@ -202,7 +198,7 @@ fun CheckBoxPreference( onChanged(!checked) } }, - modifier = combined, + modifier = modifier, trailing = { Checkbox( enabled = enabled, diff --git a/material/src/main/java/com/m3u/material/components/TextFields.kt b/material/src/main/java/com/m3u/material/components/TextFields.kt index afebdaebe..667dea9c6 100644 --- a/material/src/main/java/com/m3u/material/components/TextFields.kt +++ b/material/src/main/java/com/m3u/material/components/TextFields.kt @@ -5,7 +5,6 @@ package com.m3u.material.components import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -261,10 +260,6 @@ fun PlaceholderField( targetValue = if (focus || hasText) 12f else 14f, label = "placeholder-font-size" ) - val animFontWeight by animateIntAsState( - targetValue = if (focus || hasText) 500 else 600, - label = "placeholder-font-weight" - ) Text( modifier = Modifier @@ -276,7 +271,7 @@ fun PlaceholderField( fontSize = animPlaceHolderFontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight(animFontWeight) + fontWeight = FontWeight.SemiBold ) Box( diff --git a/ui/src/main/java/com/m3u/ui/AppSnackHost.kt b/ui/src/main/java/com/m3u/ui/AppSnackHost.kt index 76f021805..e4aa6285c 100644 --- a/ui/src/main/java/com/m3u/ui/AppSnackHost.kt +++ b/ui/src/main/java/com/m3u/ui/AppSnackHost.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -64,7 +65,19 @@ fun AppSnackHost( ) { val text = when { message.level == Message.LEVEL_EMPTY -> return@Card - message is Message.Static -> stringResource(message.resId, message.formatArgs) + message is Message.Static -> { + val args = remember(message) { + message.formatArgs.flatMap { + when (it) { + is Array<*> -> it.toList().filterNotNull() + is Collection<*> -> it.toList().filterNotNull() + else -> listOf(it) + } + }.toTypedArray() + } + stringResource(message.resId, *args) + } + message is Message.Dynamic -> message.value else -> return@Card }.replaceFirstChar { diff --git a/ui/src/main/java/com/m3u/ui/Helper.kt b/ui/src/main/java/com/m3u/ui/Helper.kt index 980789929..0510ef327 100644 --- a/ui/src/main/java/com/m3u/ui/Helper.kt +++ b/ui/src/main/java/com/m3u/ui/Helper.kt @@ -1,26 +1,16 @@ package com.m3u.ui -import android.annotation.SuppressLint import android.graphics.Rect import androidx.annotation.StringRes import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.util.Consumer -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import com.m3u.core.unspecified.UBoolean import com.m3u.core.wrapper.Message import kotlinx.collections.immutable.ImmutableList @@ -58,76 +48,22 @@ interface Helper { val Helper.useRailNav: Boolean @Composable get() = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact -private data class HelperBundle( - val title: String, - val actions: ImmutableList, - val fob: Fob?, - val statusBarsVisibility: UBoolean, - val navigationBarsVisibility: UBoolean, - val onUserLeaveHint: (() -> Unit)?, - val onPipModeChanged: Consumer?, - val darkMode: UBoolean -) { - override fun toString(): String = - "(title=$title,fob=$fob,status=$statusBarsVisibility,nav=$navigationBarsVisibility,dark=$darkMode)" -} - -private fun Helper.restore(bundle: HelperBundle) { - title = bundle.title - actions = bundle.actions - fob = bundle.fob - statusBarVisibility = bundle.statusBarsVisibility - navigationBarVisibility = bundle.navigationBarsVisibility - onUserLeaveHint = bundle.onUserLeaveHint - onPipModeChanged = bundle.onPipModeChanged - darkMode = bundle.darkMode -} +val LocalHelper = staticCompositionLocalOf { EmptyHelper } -private fun Helper.backup(): HelperBundle = HelperBundle( - title = title, - actions = actions, - fob = fob, - statusBarsVisibility = statusBarVisibility, - navigationBarsVisibility = navigationBarVisibility, - onUserLeaveHint = onUserLeaveHint, - onPipModeChanged = onPipModeChanged, - darkMode = darkMode +@Immutable +data class Action( + val icon: ImageVector, + val contentDescription: String?, + val onClick: () -> Unit ) -@Composable -@SuppressLint("ComposableNaming") -fun Helper.repeatOnLifecycle( - state: Lifecycle.State = Lifecycle.State.STARTED, - block: Helper.() -> Unit -) { - val lifecycleOwner = LocalLifecycleOwner.current - val currentBlock by rememberUpdatedState(block) - check(state != Lifecycle.State.CREATED && state != Lifecycle.State.INITIALIZED) { - "state cannot be CREATED or INITIALIZED!" - } - var bundle: HelperBundle? by remember { mutableStateOf(null) } - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.upTo(state) -> { - bundle = backup() - currentBlock() - } - - Lifecycle.Event.downFrom(state) -> { - bundle?.let { restore(it) } - } - - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } -} +@Immutable +data class Fob( + val rootDestination: Destination.Root, + val icon: ImageVector, + @StringRes val iconTextId: Int, + val onClick: () -> Unit +) val EmptyHelper = object : Helper { override var title: String @@ -212,20 +148,3 @@ val EmptyHelper = object : Helper { error("Cannot replay") } } - -val LocalHelper = staticCompositionLocalOf { EmptyHelper } - -@Immutable -data class Action( - val icon: ImageVector, - val contentDescription: String?, - val onClick: () -> Unit -) - -@Immutable -data class Fob( - val rootDestination: Destination.Root, - val icon: ImageVector, - @StringRes val iconTextId: Int, - val onClick: () -> Unit -)