mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-07-02 02:50:29 +08:00
refactor: separate TV code.
This commit is contained in:
@ -1,10 +1,7 @@
|
||||
package com.m3u.feature.channel
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@ -15,7 +12,6 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
@ -31,14 +27,11 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.VolumeDown
|
||||
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
|
||||
import androidx.compose.material.icons.rounded.Archive
|
||||
import androidx.compose.material.icons.rounded.Cast
|
||||
import androidx.compose.material.icons.rounded.DarkMode
|
||||
import androidx.compose.material.icons.rounded.HighQuality
|
||||
import androidx.compose.material.icons.rounded.LightMode
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PictureInPicture
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
@ -68,10 +61,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@ -95,7 +86,6 @@ import com.m3u.material.components.mask.MaskPanel
|
||||
import com.m3u.material.components.mask.MaskState
|
||||
import com.m3u.material.effects.currentBackStackEntry
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.FontFamilies
|
||||
import com.m3u.ui.Image
|
||||
@ -141,7 +131,6 @@ internal fun ChannelMask(
|
||||
val helper = LocalHelper.current
|
||||
val spacing = LocalSpacing.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val tv = tv()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val onBackPressedDispatcher = checkNotNull(
|
||||
@ -236,12 +225,6 @@ internal fun ChannelMask(
|
||||
}
|
||||
}
|
||||
|
||||
if (tv) {
|
||||
BackHandler(maskState.visible && !maskState.locked) {
|
||||
maskState.sleep()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
@ -314,7 +297,7 @@ internal fun ChannelMask(
|
||||
)
|
||||
}
|
||||
|
||||
if (!tv && preferences.screencast) {
|
||||
if (preferences.screencast) {
|
||||
MaskButton(
|
||||
state = maskState,
|
||||
icon = Icons.Rounded.Cast,
|
||||
@ -322,7 +305,7 @@ internal fun ChannelMask(
|
||||
contentDescription = stringResource(string.feat_channel_tooltip_cast)
|
||||
)
|
||||
}
|
||||
if (!tv && playerState.videoSize.isNotEmpty) {
|
||||
if (playerState.videoSize.isNotEmpty) {
|
||||
MaskButton(
|
||||
state = maskState,
|
||||
icon = Icons.Rounded.PictureInPicture,
|
||||
@ -453,7 +436,6 @@ internal fun ChannelMask(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!tv) {
|
||||
val autoRotating by ChannelMaskUtils.IsAutoRotatingEnabled
|
||||
LaunchedEffect(autoRotating) {
|
||||
if (autoRotating) {
|
||||
@ -474,7 +456,6 @@ internal fun ChannelMask(
|
||||
contentDescription = stringResource(string.feat_channel_tooltip_screen_rotating)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
slider = {
|
||||
when {
|
||||
@ -506,45 +487,10 @@ internal fun ChannelMask(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.basicMarquee()
|
||||
)
|
||||
var isSliderHasFocus by remember { mutableStateOf(false) }
|
||||
val tvSliderModifier = Modifier
|
||||
.onFocusChanged {
|
||||
isSliderHasFocus = it.hasFocus
|
||||
if (it.hasFocus) {
|
||||
maskState.wake()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.onKeyEvent { event ->
|
||||
when (event.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
bufferedPosition = (bufferedPosition
|
||||
?: contentPosition
|
||||
.coerceAtLeast(0L)) - 15000L
|
||||
maskState.wake()
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
bufferedPosition = (bufferedPosition
|
||||
?: contentPosition
|
||||
.coerceAtLeast(0L)) + 15000L
|
||||
maskState.wake()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val sliderThumbWidthDp by animateDpAsState(
|
||||
targetValue = if (isSliderHasFocus) 8.dp
|
||||
else 4.dp,
|
||||
targetValue = 4.dp,
|
||||
label = "slider-thumb-width-dp"
|
||||
)
|
||||
val sliderColors = SliderDefaults.colors(
|
||||
thumbColor = if (!isSliderHasFocus) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.primary.copy(0.56f)
|
||||
)
|
||||
val sliderInteractionSource = remember { MutableInteractionSource() }
|
||||
Slider(
|
||||
value = animContentPosition,
|
||||
@ -555,17 +501,13 @@ internal fun ChannelMask(
|
||||
bufferedPosition = it.roundToLong()
|
||||
maskState.wake()
|
||||
},
|
||||
colors = sliderColors,
|
||||
thumb = {
|
||||
SliderDefaults.Thumb(
|
||||
interactionSource = sliderInteractionSource,
|
||||
colors = sliderColors,
|
||||
thumbSize = DpSize(sliderThumbWidthDp, 44.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.thenIf(tv) { tvSliderModifier }
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -58,7 +58,6 @@ import com.m3u.material.components.mask.rememberMaskState
|
||||
import com.m3u.material.components.mask.toggle
|
||||
import com.m3u.material.components.rememberPullPanelLayoutState
|
||||
import com.m3u.material.ktx.checkPermissionOrRationale
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.ui.Player
|
||||
import com.m3u.ui.helper.LocalHelper
|
||||
import com.m3u.ui.helper.OnPipModeChanged
|
||||
@ -67,6 +66,7 @@ import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import androidx.core.net.toUri
|
||||
|
||||
@Composable
|
||||
fun ChannelRoute(
|
||||
@ -269,7 +269,7 @@ fun ChannelRoute(
|
||||
val channelUrl = channel?.url ?: return@DlnaDevicesBottomSheet
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.parse(channelUrl), "video/*")
|
||||
setDataAndType(channelUrl.toUri(), "video/*")
|
||||
}.let { Intent.createChooser(it, openInExternalPlayerString.title()) }
|
||||
)
|
||||
},
|
||||
@ -325,7 +325,6 @@ private fun ChannelPlayer(
|
||||
val currentVolume by rememberUpdatedState(volume)
|
||||
val currentSpeed by rememberUpdatedState(speed)
|
||||
val preferences = hiltPreferences()
|
||||
val tv = tv()
|
||||
|
||||
Background(
|
||||
color = Color.Black,
|
||||
@ -354,7 +353,7 @@ private fun ChannelPlayer(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.18f),
|
||||
enabled = !tv && preferences.brightnessGesture
|
||||
enabled = preferences.brightnessGesture
|
||||
)
|
||||
|
||||
VerticalGestureArea(
|
||||
@ -371,7 +370,7 @@ private fun ChannelPlayer(
|
||||
.align(Alignment.TopEnd)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.18f),
|
||||
enabled = !tv && preferences.volumeGesture
|
||||
enabled = preferences.volumeGesture
|
||||
)
|
||||
|
||||
val shouldShowPlaceholder =
|
||||
|
@ -7,7 +7,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import net.mm2d.upnp.Device
|
||||
|
||||
@Composable
|
||||
|
@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.mask.MaskState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.UnstableBadge
|
||||
|
@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.mask.MaskState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -16,7 +17,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.MonoText
|
||||
|
||||
|
@ -2,6 +2,7 @@ package com.m3u.feature.channel.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
@ -12,13 +13,10 @@ import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import com.m3u.material.components.mask.MaskState
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.ui.FontFamilies
|
||||
|
||||
@Composable
|
||||
@ -33,8 +31,6 @@ fun MaskTextButton(
|
||||
tint: Color = Color.Unspecified,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val tv = tv()
|
||||
|
||||
TooltipBox(
|
||||
state = tooltipState,
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
@ -48,13 +44,6 @@ fun MaskTextButton(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.thenIf(tv) {
|
||||
Modifier.onFocusEvent {
|
||||
if (it.isFocused) {
|
||||
state.wake()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (text != null) {
|
||||
Text(
|
||||
@ -65,15 +54,18 @@ fun MaskTextButton(
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
icon = icon,
|
||||
enabled = enabled,
|
||||
contentDescription = contentDescription,
|
||||
onClick = {
|
||||
state.wake()
|
||||
onClick()
|
||||
},
|
||||
tint = tint
|
||||
)
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,23 +1,16 @@
|
||||
package com.m3u.feature.channel.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@ -25,13 +18,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.coerceAtLeast
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.material.components.mask.Mask
|
||||
import com.m3u.material.components.mask.MaskState
|
||||
import com.m3u.material.ktx.plus
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
@ -46,7 +36,6 @@ internal fun PlayerMask(
|
||||
val configuration = LocalConfiguration.current
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
val tv = tv()
|
||||
Mask(
|
||||
state = state,
|
||||
color = Color.Black.copy(alpha = 0.54f),
|
||||
@ -59,7 +48,7 @@ internal fun PlayerMask(
|
||||
.padding(horizontal = spacing.medium)
|
||||
.align(Alignment.TopCenter),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
if (!tv) spacing.none else spacing.medium,
|
||||
spacing.none,
|
||||
Alignment.End
|
||||
),
|
||||
verticalAlignment = Alignment.Top,
|
||||
|
@ -1,6 +1,5 @@
|
||||
package com.m3u.feature.channel.components
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@ -34,6 +33,7 @@ import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.NotificationsActive
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@ -48,20 +48,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import com.m3u.core.util.collections.indexOf
|
||||
import com.m3u.data.database.model.Channel
|
||||
@ -71,12 +66,11 @@ import com.m3u.data.database.model.ProgrammeRange
|
||||
import com.m3u.data.service.MediaCommand
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import com.m3u.material.effects.BackStackEntry
|
||||
import com.m3u.material.effects.BackStackHandler
|
||||
import com.m3u.material.ktx.Edge
|
||||
import com.m3u.material.ktx.blurEdges
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
@ -89,10 +83,6 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
internal fun PlayerPanel(
|
||||
@ -225,15 +215,18 @@ internal fun PlayerPanel(
|
||||
if (isReminderShowing) {
|
||||
val inReminder = currentProgramme.id in programmeReminderIds
|
||||
IconButton(
|
||||
icon = if (!inReminder) Icons.Outlined.Notifications
|
||||
else Icons.Rounded.NotificationsActive,
|
||||
contentDescription = null,
|
||||
onClick = {
|
||||
if (inReminder) onCancelRemindProgramme(currentProgramme)
|
||||
else onRemindProgramme(currentProgramme)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (!inReminder) Icons.Outlined.Notifications
|
||||
else Icons.Rounded.NotificationsActive,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -345,7 +338,6 @@ fun PlayerPanelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun ChannelGallery(
|
||||
value: ChannelGalleryValue,
|
||||
@ -355,7 +347,6 @@ private fun ChannelGallery(
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val lazyListState = rememberLazyListState()
|
||||
val tv = tv()
|
||||
|
||||
ScrollToCurrentEffect(
|
||||
value = value,
|
||||
@ -395,25 +386,11 @@ private fun ChannelGallery(
|
||||
content = content
|
||||
)
|
||||
} else {
|
||||
val focusManager = LocalFocusManager.current
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium),
|
||||
contentPadding = PaddingValues(spacing.medium),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.thenIf(tv) {
|
||||
Modifier.onKeyEvent {
|
||||
when (it.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
focusManager.moveFocus(FocusDirection.Exit)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@ -441,62 +418,33 @@ private fun ChannelGalleryItem(
|
||||
val spacing = LocalSpacing.current
|
||||
val helper = LocalHelper.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val tv = tv()
|
||||
|
||||
if (!tv) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (!isPlaying)
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.medium)
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
contentColor = if (!isPlaying) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.small)
|
||||
),
|
||||
shape = AbsoluteRoundedCornerShape(spacing.medium),
|
||||
elevation = CardDefaults.cardElevation(spacing.none),
|
||||
onClick = {
|
||||
if (isPlaying) return@Card
|
||||
coroutineScope.launch {
|
||||
helper.play(
|
||||
MediaCommand.Common(channel.id)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = channel.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold.takeIf { isPlaying },
|
||||
modifier = Modifier.padding(spacing.medium)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvCard(
|
||||
colors = TvCardDefaults.colors(
|
||||
containerColor = if (!isPlaying)
|
||||
TvMaterialTheme.colorScheme.surfaceColorAtElevation(spacing.medium)
|
||||
else TvMaterialTheme.colorScheme.onSurface,
|
||||
contentColor = if (!isPlaying) TvMaterialTheme.colorScheme.onSurface
|
||||
else TvMaterialTheme.colorScheme.surfaceColorAtElevation(spacing.small)
|
||||
),
|
||||
onClick = {
|
||||
if (isPlaying) return@TvCard
|
||||
coroutineScope.launch {
|
||||
helper.play(
|
||||
MediaCommand.Common(channel.id)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold.takeIf { isPlaying },
|
||||
modifier = Modifier.padding(spacing.medium)
|
||||
)
|
||||
}
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (!isPlaying)
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.medium)
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
contentColor = if (!isPlaying) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.small)
|
||||
),
|
||||
shape = AbsoluteRoundedCornerShape(spacing.medium),
|
||||
elevation = CardDefaults.cardElevation(spacing.none),
|
||||
onClick = {
|
||||
if (isPlaying) return@Card
|
||||
coroutineScope.launch {
|
||||
helper.play(
|
||||
MediaCommand.Common(channel.id)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = channel.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold.takeIf { isPlaying },
|
||||
modifier = Modifier.padding(spacing.medium)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,11 +62,9 @@ import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.data.database.model.Programme
|
||||
import com.m3u.data.database.model.ProgrammeRange
|
||||
import com.m3u.data.database.model.ProgrammeRange.Companion.HOUR_LENGTH
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.ktx.Edge
|
||||
import com.m3u.material.ktx.blurEdges
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.FontFamilies
|
||||
import com.m3u.ui.util.TimeUtils.formatEOrSh
|
||||
@ -85,9 +83,6 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults as TvClickableSurfaceDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Surface as TvSurface
|
||||
|
||||
private enum class Zoom(val time: Float) {
|
||||
DEFAULT(1f), ZOOM_1_5(1.5f), ZOOM_2(2f), ZOOM_5(5f)
|
||||
@ -108,7 +103,6 @@ internal fun ProgramGuide(
|
||||
onProgrammePressed: (Programme) -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val tv = tv()
|
||||
|
||||
val currentMilliseconds by produceCurrentMillisecondState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@ -160,7 +154,7 @@ internal fun ProgramGuide(
|
||||
MaterialTheme.colorScheme.surface,
|
||||
listOf(Edge.Top, Edge.Bottom)
|
||||
)
|
||||
.thenIf(!tv) { zoomGestureModifier }
|
||||
.then(zoomGestureModifier)
|
||||
.then(modifier)
|
||||
) {
|
||||
// programmes
|
||||
@ -223,17 +217,14 @@ internal fun ProgramGuide(
|
||||
)
|
||||
}) {}
|
||||
}
|
||||
|
||||
if (!tv) {
|
||||
Controls(
|
||||
animateToCurrentTimeline = {
|
||||
coroutineScope.launch { animateToCurrentTimeline() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
Controls(
|
||||
animateToCurrentTimeline = {
|
||||
coroutineScope.launch { animateToCurrentTimeline() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -303,7 +294,6 @@ private fun ProgrammeCell(
|
||||
val currentOnPressed by rememberUpdatedState(onPressed)
|
||||
val spacing = LocalSpacing.current
|
||||
val preferences = hiltPreferences()
|
||||
val tv = tv()
|
||||
val clockMode = preferences.twelveHourClock
|
||||
val content = @Composable {
|
||||
Column(
|
||||
@ -341,71 +331,61 @@ private fun ProgrammeCell(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!tv) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.95f else 1f,
|
||||
label = "programme-cell-scale",
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioHighBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.95f else 1f,
|
||||
label = "programme-cell-scale",
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioHighBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
val currentColor by animateColorAsState(
|
||||
targetValue = if(inReminder) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.tertiaryContainer,
|
||||
label = "programme-cell-color"
|
||||
)
|
||||
val currentContentColor by animateColorAsState(
|
||||
targetValue = if(inReminder) MaterialTheme.colorScheme.onTertiary
|
||||
else MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
label = "programme-cell-color"
|
||||
)
|
||||
Surface(
|
||||
color = currentColor,
|
||||
contentColor = currentContentColor,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
shape = AbsoluteRoundedCornerShape(4.dp),
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
try {
|
||||
withTimeout(viewConfiguration.longPressTimeoutMillis) {
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
down.consume()
|
||||
currentOnPressed()
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
isPressed = true
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
event.changes.fastForEach { it.consume() }
|
||||
} while (event.changes.fastAny { it.pressed })
|
||||
isPressed = false
|
||||
} finally {
|
||||
isPressed = false
|
||||
)
|
||||
val currentColor by animateColorAsState(
|
||||
targetValue = if (inReminder) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.tertiaryContainer,
|
||||
label = "programme-cell-color"
|
||||
)
|
||||
val currentContentColor by animateColorAsState(
|
||||
targetValue = if (inReminder) MaterialTheme.colorScheme.onTertiary
|
||||
else MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
label = "programme-cell-color"
|
||||
)
|
||||
Surface(
|
||||
color = currentColor,
|
||||
contentColor = currentContentColor,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
shape = AbsoluteRoundedCornerShape(4.dp),
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
try {
|
||||
withTimeout(viewConfiguration.longPressTimeoutMillis) {
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
down.consume()
|
||||
currentOnPressed()
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
isPressed = true
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
event.changes.fastForEach { it.consume() }
|
||||
} while (event.changes.fastAny { it.pressed })
|
||||
isPressed = false
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.then(modifier),
|
||||
content = content
|
||||
)
|
||||
} else {
|
||||
TvSurface(
|
||||
onClick = onPressed,
|
||||
colors = TvClickableSurfaceDefaults.colors(TvMaterialTheme.colorScheme.tertiaryContainer),
|
||||
shape = TvClickableSurfaceDefaults.shape(AbsoluteRoundedCornerShape(4.dp)),
|
||||
modifier = modifier,
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.then(modifier),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -11,7 +11,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m3u.feature.channel.ChannelMaskUtils.detectVerticalGesture
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
|
||||
@Composable
|
||||
|
@ -2,7 +2,7 @@ package com.m3u.feature.crash.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Adb
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
@ -19,7 +19,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.MonoText
|
||||
|
||||
|
@ -33,7 +33,6 @@ import com.m3u.data.service.MediaCommand
|
||||
import com.m3u.feature.favorite.components.FavouriteGallery
|
||||
import com.m3u.i18n.R
|
||||
import com.m3u.material.ktx.interceptVolumeEvent
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.ui.EpisodesBottomSheet
|
||||
@ -41,7 +40,6 @@ import com.m3u.ui.MediaSheet
|
||||
import com.m3u.ui.MediaSheetValue
|
||||
import com.m3u.ui.Sort
|
||||
import com.m3u.ui.SortBottomSheet
|
||||
import com.m3u.ui.TvSortFullScreenDialog
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.LocalHelper
|
||||
import com.m3u.ui.helper.Metadata
|
||||
@ -56,8 +54,6 @@ fun FavouriteRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FavouriteViewModel = hiltViewModel()
|
||||
) {
|
||||
val tv = tv()
|
||||
|
||||
val title = stringResource(R.string.ui_title_favourite)
|
||||
|
||||
val helper = LocalHelper.current
|
||||
@ -119,13 +115,9 @@ fun FavouriteRoute(
|
||||
}
|
||||
},
|
||||
onLongClickChannel = { mediaSheetValue = MediaSheetValue.FavouriteScreen(it) },
|
||||
onClickRandomTips = {
|
||||
viewModel.playRandomly()
|
||||
navigateToChannel()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.thenIf(!tv && preferences.godMode) {
|
||||
.thenIf(preferences.godMode) {
|
||||
Modifier.interceptVolumeEvent { event ->
|
||||
preferences.rowCount = when (event) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP ->
|
||||
@ -159,39 +151,29 @@ fun FavouriteRoute(
|
||||
onRefresh = { series?.let { viewModel.seriesReplay.value += 1 } },
|
||||
onDismissRequest = { viewModel.series.value = null }
|
||||
)
|
||||
if (!tv) {
|
||||
SortBottomSheet(
|
||||
visible = isSortSheetVisible,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
sheetState = sheetState,
|
||||
onChanged = { viewModel.sort(it) },
|
||||
onDismissRequest = { isSortSheetVisible = false }
|
||||
)
|
||||
MediaSheet(
|
||||
value = mediaSheetValue,
|
||||
onFavouriteChannel = { channel ->
|
||||
viewModel.favourite(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
},
|
||||
onCreateShortcut = { channel ->
|
||||
viewModel.createShortcut(context, channel.id)
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
},
|
||||
onDismissRequest = {
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
TvSortFullScreenDialog(
|
||||
visible = (mediaSheetValue as? MediaSheetValue.FavouriteScreen)?.channel != null,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
onChanged = { viewModel.sort(it) },
|
||||
onDismissRequest = { mediaSheetValue = MediaSheetValue.FavouriteScreen() }
|
||||
)
|
||||
}
|
||||
SortBottomSheet(
|
||||
visible = isSortSheetVisible,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
sheetState = sheetState,
|
||||
onChanged = { viewModel.sort(it) },
|
||||
onDismissRequest = { isSortSheetVisible = false }
|
||||
)
|
||||
MediaSheet(
|
||||
value = mediaSheetValue,
|
||||
onFavouriteChannel = { channel ->
|
||||
viewModel.favourite(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
},
|
||||
onCreateShortcut = { channel ->
|
||||
viewModel.createShortcut(context, channel.id)
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
},
|
||||
onDismissRequest = {
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
mediaSheetValue = MediaSheetValue.FavouriteScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -203,7 +185,6 @@ private fun FavoriteScreen(
|
||||
recently: Boolean,
|
||||
onClickChannel: (Channel) -> Unit,
|
||||
onLongClickChannel: (Channel) -> Unit,
|
||||
onClickRandomTips: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
@ -220,7 +201,6 @@ private fun FavoriteScreen(
|
||||
rowCount = actualRowCount,
|
||||
onClick = onClickChannel,
|
||||
onLongClick = onLongClickChannel,
|
||||
onClickRandomTips = onClickRandomTips,
|
||||
modifier = modifier.haze(
|
||||
LocalHazeState.current,
|
||||
HazeDefaults.style(MaterialTheme.colorScheme.surface)
|
||||
|
@ -1,7 +1,5 @@
|
||||
package com.m3u.feature.favorite.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@ -14,35 +12,15 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.core.wrapper.Resource
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.VerticalDraggableScrollbar
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.plus
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.createPremiumBrush
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.Glow as TvGlow
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
internal fun FavouriteGallery(
|
||||
@ -53,7 +31,6 @@ internal fun FavouriteGallery(
|
||||
rowCount: Int,
|
||||
onClick: (Channel) -> Unit,
|
||||
onLongClick: (Channel) -> Unit,
|
||||
onClickRandomTips: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
@ -116,73 +93,3 @@ internal fun FavouriteGallery(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RandomTips(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val tv = tv()
|
||||
val title = stringResource(string.feat_favorite_play_randomly)
|
||||
if (!tv) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title.title(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(Color.Transparent),
|
||||
modifier = Modifier
|
||||
.clip(AbsoluteRoundedCornerShape(spacing.medium))
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
Brush.createPremiumBrush(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
)
|
||||
.then(modifier)
|
||||
)
|
||||
} else {
|
||||
TvCard(
|
||||
onClick = onClick,
|
||||
glow = TvCardDefaults.glow(
|
||||
TvGlow(
|
||||
elevationColor = Color.Transparent,
|
||||
elevation = spacing.small
|
||||
)
|
||||
),
|
||||
scale = TvCardDefaults.scale(
|
||||
scale = 0.95f,
|
||||
focusedScale = 1f
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.createPremiumBrush(
|
||||
TvMaterialTheme.colorScheme.primary,
|
||||
TvMaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
)
|
||||
.padding(spacing.medium)
|
||||
.then(modifier)
|
||||
) {
|
||||
TvText(
|
||||
text = title.title(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = TvMaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
import kotlinx.datetime.Clock
|
||||
@ -27,9 +26,6 @@ import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import androidx.tv.material3.ListItem as TvListItem
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
internal fun FavoriteItem(
|
||||
@ -39,35 +35,6 @@ internal fun FavoriteItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
SmartphoneFavoriteItemImpl(
|
||||
channel = channel,
|
||||
recently = recently,
|
||||
zapping = zapping,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
TvFavouriteItemImpl(
|
||||
channel = channel,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmartphoneFavoriteItemImpl(
|
||||
channel: Channel,
|
||||
recently: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
zapping: Boolean = false
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
@ -123,25 +90,3 @@ private fun SmartphoneFavoriteItemImpl(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvFavouriteItemImpl(
|
||||
channel: Channel,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TvListItem(
|
||||
selected = false,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
style = TvMaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
@ -46,7 +46,6 @@ import com.m3u.feature.foryou.components.recommend.RecommendGallery
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.ktx.composableOf
|
||||
import com.m3u.material.ktx.interceptVolumeEvent
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.ui.EpisodesBottomSheet
|
||||
import com.m3u.ui.MediaSheet
|
||||
@ -72,7 +71,6 @@ fun ForyouRoute(
|
||||
val preferences = hiltPreferences()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val tv = tv()
|
||||
val title = stringResource(string.ui_title_foryou)
|
||||
|
||||
val playlistCounts by viewModel.playlistCounts.collectAsStateWithLifecycle()
|
||||
@ -128,7 +126,7 @@ fun ForyouRoute(
|
||||
onUnsubscribePlaylist = viewModel::onUnsubscribePlaylist,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.thenIf(!tv && preferences.godMode) {
|
||||
.thenIf(preferences.godMode) {
|
||||
Modifier.interceptVolumeEvent { event ->
|
||||
preferences.rowCount = when (event) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> (preferences.rowCount - 1).coerceAtLeast(1)
|
||||
|
@ -1,12 +1,10 @@
|
||||
package com.m3u.feature.foryou.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.DriveFileMove
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@ -19,7 +17,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@ -30,15 +27,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.ktx.tv
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
import com.m3u.ui.Badge
|
||||
import com.m3u.ui.FontFamilies
|
||||
import androidx.tv.material3.ListItem as TvListItem
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
internal fun PlaylistItem(
|
||||
@ -50,42 +43,6 @@ internal fun PlaylistItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
SmartphonePlaylistItemImpl(
|
||||
label = label,
|
||||
type = type,
|
||||
count = count,
|
||||
local = local,
|
||||
subscribing = subscribingOrRefreshing,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
TvPlaylistItemImpl(
|
||||
label = label,
|
||||
type = type,
|
||||
count = count,
|
||||
subscribing = subscribingOrRefreshing,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmartphonePlaylistItemImpl(
|
||||
label: String,
|
||||
type: String?,
|
||||
count: Int,
|
||||
local: Boolean,
|
||||
subscribing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
OutlinedCard(
|
||||
@ -129,7 +86,7 @@ private fun SmartphonePlaylistItemImpl(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.extraSmall)
|
||||
) {
|
||||
if (subscribing) {
|
||||
if (subscribingOrRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
color = LocalContentColor.current,
|
||||
size = 8.dp
|
||||
@ -181,73 +138,3 @@ private fun SmartphonePlaylistItemImpl(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlaylistItemImpl(
|
||||
label: String,
|
||||
type: String?,
|
||||
count: Int,
|
||||
subscribing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val theme = TvMaterialTheme.colorScheme
|
||||
TvListItem(
|
||||
selected = false,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
TvText(
|
||||
text = type?.uppercase().orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = TvMaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamilies.LexendExa
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(AbsoluteSmoothCornerShape(spacing.small, 65))
|
||||
.background(theme.primary)
|
||||
.padding(horizontal = spacing.small),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.extraSmall)
|
||||
) {
|
||||
if (subscribing) {
|
||||
CircularProgressIndicator(
|
||||
color = theme.onPrimary
|
||||
)
|
||||
}
|
||||
TvText(
|
||||
color = theme.onPrimary,
|
||||
text = count.toString(),
|
||||
style = TvMaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.None
|
||||
)
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(
|
||||
bottom = 2.dp,
|
||||
),
|
||||
softWrap = false,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamilies.LexendExa
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,5 @@
|
||||
package com.m3u.feature.foryou.components.recommend
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@ -23,11 +17,9 @@ import com.m3u.core.wrapper.eventOf
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.data.database.model.Playlist
|
||||
import com.m3u.material.components.HorizontalPagerIndicator
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.pageOffset
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.Events
|
||||
import androidx.tv.material3.Carousel as TvCarousel
|
||||
|
||||
@Composable
|
||||
internal fun RecommendGallery(
|
||||
@ -40,8 +32,6 @@ internal fun RecommendGallery(
|
||||
val spacing = LocalSpacing.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
val tv = tv()
|
||||
|
||||
val onClick = { spec: Recommend.Spec ->
|
||||
when (spec) {
|
||||
is Recommend.UnseenSpec -> {
|
||||
@ -59,59 +49,35 @@ internal fun RecommendGallery(
|
||||
}
|
||||
}
|
||||
|
||||
if (!tv) {
|
||||
val state = rememberPagerState { specs.size }
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium)
|
||||
) {
|
||||
DisposableEffect(state.currentPage) {
|
||||
onSpecChanged(specs[state.currentPage])
|
||||
onDispose {
|
||||
onSpecChanged(null)
|
||||
}
|
||||
val state = rememberPagerState { specs.size }
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium)
|
||||
) {
|
||||
DisposableEffect(state.currentPage) {
|
||||
onSpecChanged(specs[state.currentPage])
|
||||
onDispose {
|
||||
onSpecChanged(null)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = state,
|
||||
contentPadding = PaddingValues(horizontal = spacing.medium),
|
||||
modifier = Modifier.height(128.dp)
|
||||
) { page ->
|
||||
val spec = specs[page]
|
||||
val pageOffset = state.pageOffset(page)
|
||||
RecommendItem(
|
||||
spec = spec,
|
||||
pageOffset = pageOffset,
|
||||
onClick = { onClick(spec) }
|
||||
)
|
||||
}
|
||||
HorizontalPagerIndicator(
|
||||
pagerState = state,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = spacing.medium),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvCarousel(
|
||||
itemCount = specs.size,
|
||||
contentTransformEndToStart =
|
||||
fadeIn(tween(1000)) togetherWith fadeOut(tween(1000)),
|
||||
contentTransformStartToEnd =
|
||||
fadeIn(tween(1000)) togetherWith fadeOut(tween(1000)),
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.then(modifier)
|
||||
) { index ->
|
||||
val spec = specs[index]
|
||||
HorizontalPager(
|
||||
state = state,
|
||||
contentPadding = PaddingValues(horizontal = spacing.medium),
|
||||
modifier = Modifier.height(128.dp)
|
||||
) { page ->
|
||||
val spec = specs[page]
|
||||
val pageOffset = state.pageOffset(page)
|
||||
RecommendItem(
|
||||
spec = spec,
|
||||
pageOffset = 0f,
|
||||
onClick = { onClick(spec) },
|
||||
modifier = Modifier.animateEnterExit(
|
||||
enter = slideInHorizontally(animationSpec = tween(1000)) { it / 2 },
|
||||
exit = slideOutHorizontally(animationSpec = tween(1000))
|
||||
)
|
||||
pageOffset = pageOffset,
|
||||
onClick = { onClick(spec) }
|
||||
)
|
||||
}
|
||||
HorizontalPagerIndicator(
|
||||
pagerState = state,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = spacing.medium),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,6 @@ import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.brush.RecommendCardContainerBrush
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
import com.m3u.ui.FontFamilies
|
||||
@ -51,10 +50,6 @@ import com.m3u.ui.createPremiumBrush
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.CardScale as TvCardScale
|
||||
import androidx.tv.material3.LocalContentColor as TvLocalContentColor
|
||||
|
||||
@Composable
|
||||
internal fun RecommendItem(
|
||||
@ -80,37 +75,25 @@ private fun RecommendItemLayout(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
Card(
|
||||
shape = AbsoluteSmoothCornerShape(spacing.medium, 65),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
lerp(
|
||||
start = 0.65f,
|
||||
stop = 1f,
|
||||
fraction = 1f - pageOffset.coerceIn(0f, 1f)
|
||||
).also { scale ->
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
Card(
|
||||
shape = AbsoluteSmoothCornerShape(spacing.medium, 65),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
lerp(
|
||||
start = 0.65f,
|
||||
stop = 1f,
|
||||
fraction = 1f - pageOffset.coerceIn(0f, 1f)
|
||||
).also { scale ->
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.fillMaxHeight()
|
||||
.then(modifier),
|
||||
content = { content() }
|
||||
)
|
||||
} else {
|
||||
TvCard(
|
||||
scale = TvCardScale.None,
|
||||
shape = TvCardDefaults.shape(AbsoluteSmoothCornerShape(spacing.medium, 65)),
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
.fillMaxHeight()
|
||||
.then(modifier),
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -186,7 +169,6 @@ fun UnseenContent(spec: Recommend.UnseenSpec) {
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides Color.White,
|
||||
TvLocalContentColor provides Color.White,
|
||||
) {
|
||||
info()
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ import com.m3u.feature.playlist.configuration.components.SyncProgrammesButton
|
||||
import com.m3u.feature.playlist.configuration.components.XtreamPanel
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.PlaceholderField
|
||||
import com.m3u.material.ktx.checkPermissionOrRationale
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
|
@ -4,6 +4,8 @@ package com.m3u.feature.playlist
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.content.res.Configuration.ORIENTATION_PORTRAIT
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_APPLIANCE
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_CAR
|
||||
import android.content.res.Configuration.UI_MODE_TYPE_DESK
|
||||
@ -15,13 +17,30 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.background
|
||||
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.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.material.BackdropScaffold
|
||||
import androidx.compose.material.BackdropValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.rememberBackdropScaffoldState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
@ -32,14 +51,29 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
@ -58,29 +92,37 @@ import com.m3u.data.database.model.isSeries
|
||||
import com.m3u.data.database.model.isVod
|
||||
import com.m3u.data.database.model.type
|
||||
import com.m3u.data.service.MediaCommand
|
||||
import com.m3u.feature.playlist.internal.SmartphonePlaylistScreenImpl
|
||||
import com.m3u.feature.playlist.internal.TvPlaylistScreenImpl
|
||||
import com.m3u.feature.playlist.components.PlaylistTabRow
|
||||
import com.m3u.feature.playlist.components.ChannelGallery
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.TextField
|
||||
import com.m3u.material.ktx.checkPermissionOrRationale
|
||||
import com.m3u.material.ktx.createScheme
|
||||
import com.m3u.material.ktx.interceptVolumeEvent
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.isAtTop
|
||||
import com.m3u.material.ktx.only
|
||||
import com.m3u.material.ktx.split
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.model.asTvScheme
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.EpisodesBottomSheet
|
||||
import com.m3u.ui.EventHandler
|
||||
import com.m3u.ui.MediaSheet
|
||||
import com.m3u.ui.MediaSheetValue
|
||||
import com.m3u.ui.Sort
|
||||
import com.m3u.ui.SortBottomSheet
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.Fob
|
||||
import com.m3u.ui.helper.LocalHelper
|
||||
import com.m3u.ui.helper.Metadata
|
||||
import dev.chrisbanes.haze.HazeDefaults
|
||||
import dev.chrisbanes.haze.haze
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
|
||||
@Composable
|
||||
internal fun PlaylistRoute(
|
||||
@ -93,11 +135,9 @@ internal fun PlaylistRoute(
|
||||
val preferences = hiltPreferences()
|
||||
val helper = LocalHelper.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val colorScheme = TvMaterialTheme.colorScheme
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val tv = tv()
|
||||
|
||||
val zapping by viewModel.zapping.collectAsStateWithLifecycle()
|
||||
val playlistUrl by viewModel.playlistUrl.collectAsStateWithLifecycle()
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||
@ -120,7 +160,8 @@ internal fun PlaylistRoute(
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val scrollUp by viewModel.scrollUp.collectAsStateWithLifecycle()
|
||||
|
||||
val writeExternalPermission = rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
val writeExternalPermission =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
||||
val postNotificationPermission = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) null
|
||||
else rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@ -241,13 +282,12 @@ internal fun PlaylistRoute(
|
||||
}
|
||||
},
|
||||
createShortcut = { id -> viewModel.createShortcut(context, id) },
|
||||
createTvRecommend = { id -> viewModel.createTvRecommend(helper.activityContext, id) },
|
||||
isVodPlaylist = isVodPlaylist,
|
||||
isSeriesPlaylist = isSeriesPlaylist,
|
||||
getProgrammeCurrently = { channelId -> viewModel.getProgrammeCurrently(channelId) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.thenIf(!tv && preferences.godMode) {
|
||||
.thenIf(preferences.godMode) {
|
||||
Modifier.interceptVolumeEvent { event ->
|
||||
preferences.rowCount = when (event) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP ->
|
||||
@ -311,7 +351,6 @@ private fun PlaylistScreen(
|
||||
hide: (channelId: Int) -> Unit,
|
||||
savePicture: (channelId: Int) -> Unit,
|
||||
createShortcut: (channelId: Int) -> Unit,
|
||||
createTvRecommend: (channelId: Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
isVodPlaylist: Boolean,
|
||||
isSeriesPlaylist: Boolean,
|
||||
@ -343,61 +382,216 @@ private fun PlaylistScreen(
|
||||
onDispose { Metadata.fob = null }
|
||||
}
|
||||
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
SmartphonePlaylistScreenImpl(
|
||||
categoryWithChannels = categoryWithChannels,
|
||||
pinnedCategories = pinnedCategories,
|
||||
onPinOrUnpinCategory = onPinOrUnpinCategory,
|
||||
onHideCategory = onHideCategory,
|
||||
zapping = zapping,
|
||||
query = query,
|
||||
onQuery = onQuery,
|
||||
rowCount = rowCount,
|
||||
scrollUp = scrollUp,
|
||||
contentPadding = contentPadding,
|
||||
onPlayChannel = onPlayChannel,
|
||||
isAtTopState = isAtTopState,
|
||||
refreshing = refreshing,
|
||||
onRefresh = onRefresh,
|
||||
sorts = sorts,
|
||||
sort = sort,
|
||||
onSort = onSort,
|
||||
favourite = favourite,
|
||||
onHide = hide,
|
||||
onSaveCover = savePicture,
|
||||
onCreateShortcut = createShortcut,
|
||||
isVodOrSeriesPlaylist = isVodPlaylist || isSeriesPlaylist,
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val preferences = hiltPreferences()
|
||||
TvMaterialTheme(
|
||||
colorScheme = remember(preferences.argb) {
|
||||
createScheme(preferences.argb, true).asTvScheme()
|
||||
val spacing = LocalSpacing.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val scaffoldState = rememberBackdropScaffoldState(
|
||||
initialValue = BackdropValue.Concealed
|
||||
)
|
||||
val connection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
return if (scaffoldState.isRevealed) available
|
||||
else Offset.Zero
|
||||
}
|
||||
) {
|
||||
TvPlaylistScreenImpl(
|
||||
title = title,
|
||||
categoryWithChannels = categoryWithChannels,
|
||||
query = query,
|
||||
onQuery = onQuery,
|
||||
onPlayChannel = onPlayChannel,
|
||||
onRefresh = onRefresh,
|
||||
sorts = sorts,
|
||||
sort = sort,
|
||||
onSort = onSort,
|
||||
favorite = favourite,
|
||||
hide = hide,
|
||||
savePicture = savePicture,
|
||||
createTvRecommend = createTvRecommend,
|
||||
isVodOrSeriesPlaylist = isVodPlaylist || isSeriesPlaylist,
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentColor = MaterialTheme.colorScheme.background
|
||||
val currentContentColor = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
var mediaSheetValue: MediaSheetValue.PlaylistScreen by remember { mutableStateOf(MediaSheetValue.PlaylistScreen()) }
|
||||
var isSortSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LifecycleResumeEffect(refreshing) {
|
||||
Metadata.actions = buildList {
|
||||
Action(
|
||||
icon = Icons.AutoMirrored.Rounded.Sort,
|
||||
contentDescription = "sort",
|
||||
onClick = { isSortSheetVisible = true }
|
||||
).also { add(it) }
|
||||
Action(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
enabled = !refreshing,
|
||||
contentDescription = "refresh",
|
||||
onClick = onRefresh
|
||||
).also { add(it) }
|
||||
}
|
||||
onPauseOrDispose {
|
||||
Metadata.actions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val categories = remember(categoryWithChannels) { categoryWithChannels.map { it.category } }
|
||||
var category by remember(categories) { mutableStateOf(categories.firstOrNull().orEmpty()) }
|
||||
|
||||
val (inner, outer) = contentPadding split WindowInsetsSides.Bottom
|
||||
|
||||
BackdropScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
gesturesEnabled = isAtTopState.value,
|
||||
appBar = {},
|
||||
frontLayerShape = RectangleShape,
|
||||
peekHeight = 0.dp,
|
||||
backLayerContent = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(scaffoldState.currentValue) {
|
||||
if (scaffoldState.isConcealed) {
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
BackHandler(scaffoldState.isRevealed || query.isNotEmpty()) {
|
||||
if (scaffoldState.isRevealed) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.conceal()
|
||||
}
|
||||
}
|
||||
if (query.isNotEmpty()) {
|
||||
onQuery("")
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextField(
|
||||
text = query,
|
||||
onValueChange = onQuery,
|
||||
fontWeight = FontWeight.Bold,
|
||||
placeholder = stringResource(string.feat_playlist_query_placeholder).uppercase(),
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(max = 48.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
frontLayerContent = {
|
||||
val state = rememberLazyStaggeredGridState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { state.isAtTop }
|
||||
.onEach { isAtTopState.value = it }
|
||||
.launchIn(this)
|
||||
}
|
||||
EventHandler(scrollUp) {
|
||||
state.scrollToItem(0)
|
||||
}
|
||||
val orientation = configuration.orientation
|
||||
val actualRowCount = remember(orientation, rowCount) {
|
||||
when (orientation) {
|
||||
ORIENTATION_LANDSCAPE -> rowCount + 2
|
||||
ORIENTATION_PORTRAIT -> rowCount
|
||||
else -> rowCount
|
||||
}
|
||||
}
|
||||
var isExpanded by remember(sort == Sort.MIXED) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
BackHandler(isExpanded) { isExpanded = false }
|
||||
|
||||
val tabs = @Composable {
|
||||
PlaylistTabRow(
|
||||
selectedCategory = category,
|
||||
categories = categories,
|
||||
isExpanded = isExpanded,
|
||||
bottomContentPadding = contentPadding only WindowInsetsSides.Bottom,
|
||||
onExpanded = { isExpanded = !isExpanded },
|
||||
onCategoryChanged = { category = it },
|
||||
pinnedCategories = pinnedCategories,
|
||||
onPinOrUnpinCategory = onPinOrUnpinCategory,
|
||||
onHideCategory = onHideCategory
|
||||
)
|
||||
}
|
||||
|
||||
val gallery = @Composable {
|
||||
val channel = remember(categoryWithChannels, category) {
|
||||
categoryWithChannels.find { it.category == category }
|
||||
}
|
||||
ChannelGallery(
|
||||
state = state,
|
||||
rowCount = actualRowCount,
|
||||
categoryWithChannels = channel,
|
||||
zapping = zapping,
|
||||
recently = sort == Sort.RECENTLY,
|
||||
isVodOrSeriesPlaylist = isVodPlaylist || isSeriesPlaylist,
|
||||
onClick = onPlayChannel,
|
||||
contentPadding = inner,
|
||||
onLongClick = {
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen(it)
|
||||
},
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
modifier = Modifier.haze(
|
||||
LocalHazeState.current,
|
||||
HazeDefaults.style(MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
) {
|
||||
if (!isExpanded) {
|
||||
AnimatedVisibility(
|
||||
visible = categories.size > 1,
|
||||
enter = fadeIn(animationSpec = tween(400))
|
||||
) {
|
||||
tabs()
|
||||
}
|
||||
gallery()
|
||||
} else {
|
||||
AnimatedVisibility(
|
||||
visible = categories.size > 1,
|
||||
enter = fadeIn(animationSpec = tween(400))
|
||||
) {
|
||||
tabs()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backLayerBackgroundColor = Color.Transparent,
|
||||
backLayerContentColor = currentContentColor,
|
||||
frontLayerScrimColor = currentColor.copy(alpha = 0.45f),
|
||||
frontLayerBackgroundColor = Color.Transparent,
|
||||
modifier = modifier
|
||||
.padding(outer)
|
||||
.nestedScroll(
|
||||
connection = connection
|
||||
)
|
||||
)
|
||||
|
||||
SortBottomSheet(
|
||||
visible = isSortSheetVisible,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
sheetState = sheetState,
|
||||
onChanged = onSort,
|
||||
onDismissRequest = { isSortSheetVisible = false }
|
||||
)
|
||||
|
||||
MediaSheet(
|
||||
value = mediaSheetValue,
|
||||
onFavouriteChannel = { channel ->
|
||||
favourite(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onHideChannel = { channel ->
|
||||
hide(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onSaveChannelCover = { channel ->
|
||||
savePicture(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onCreateShortcut = { channel ->
|
||||
createShortcut(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onDismissRequest = { mediaSheetValue = MediaSheetValue.PlaylistScreen() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -1,80 +0,0 @@
|
||||
package com.m3u.feature.playlist
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.m3u.core.Contracts
|
||||
import com.m3u.ui.Events.enableDPadReaction
|
||||
import com.m3u.ui.Toolkit
|
||||
import com.m3u.ui.helper.Helper
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TvPlaylistActivity : AppCompatActivity() {
|
||||
private val helper: Helper = Helper(this)
|
||||
private val viewModel: PlaylistViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
enableDPadReaction()
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
Toolkit(helper) {
|
||||
PlaylistRoute(
|
||||
viewModel = viewModel,
|
||||
navigateToChannel = ::navigateToChannel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val intentAction = intent.action
|
||||
if (intentAction == Intent.ACTION_VIEW) {
|
||||
val intentData = intent.data
|
||||
val pathSegments = intentData?.pathSegments ?: emptyList()
|
||||
when (pathSegments.firstOrNull()) {
|
||||
"discover" -> {
|
||||
val channelId = pathSegments[1].toIntOrNull() ?: return
|
||||
viewModel.setup(channelId) {
|
||||
lifecycleScope.launch {
|
||||
helper.play(it)
|
||||
navigateToChannel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToChannel() {
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
0,
|
||||
0
|
||||
)
|
||||
startActivity(
|
||||
Intent().apply {
|
||||
component = ComponentName.createRelative(
|
||||
this@TvPlaylistActivity,
|
||||
Contracts.PLAYER_ACTIVITY
|
||||
)
|
||||
},
|
||||
options.toBundle()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
helper.applyConfiguration()
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ import com.m3u.material.ktx.plus
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
internal fun SmartphoneChannelGallery(
|
||||
internal fun ChannelGallery(
|
||||
state: LazyStaggeredGridState,
|
||||
rowCount: Int,
|
||||
categoryWithChannels: PlaylistViewModel.CategoryWithChannels?,
|
||||
@ -80,7 +80,7 @@ internal fun SmartphoneChannelGallery(
|
||||
) {
|
||||
value = currentGetProgrammeCurrently(channel.id)
|
||||
}
|
||||
SmartphoneChannelItem(
|
||||
ChannelItem(
|
||||
channel = channel,
|
||||
programme = programme,
|
||||
recently = recently,
|
@ -45,7 +45,7 @@ import com.m3u.data.database.model.Programme
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
import com.m3u.ui.util.TimeUtils.formatEOrSh
|
||||
@ -59,7 +59,7 @@ import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
internal fun SmartphoneChannelItem(
|
||||
internal fun ChannelItem(
|
||||
channel: Channel,
|
||||
recently: Boolean,
|
||||
zapping: Boolean,
|
@ -1,159 +0,0 @@
|
||||
package com.m3u.feature.playlist.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.data.database.model.Programme
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.material.brush.ImmersiveBackgroundBrush
|
||||
import com.m3u.material.components.IconButton
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.SnackHost
|
||||
import androidx.tv.material3.LocalContentColor as TvLocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
internal fun ImmersiveBackground(
|
||||
title: String,
|
||||
channel: Channel?,
|
||||
maxBrowserHeight: Dp,
|
||||
onRefresh: () -> Unit,
|
||||
openSearchDrawer: () -> Unit,
|
||||
openSortDrawer: () -> Unit,
|
||||
getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val spacing = LocalSpacing.current
|
||||
val preferences = hiltPreferences()
|
||||
|
||||
val noPictureMode = preferences.noPictureMode
|
||||
|
||||
val currentGetProgrammeCurrently by rememberUpdatedState(getProgrammeCurrently)
|
||||
|
||||
Box(modifier) {
|
||||
if (channel != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
if (!noPictureMode) {
|
||||
val request = remember(channel.cover) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(channel.cover.orEmpty())
|
||||
.crossfade(1600)
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = channel.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.78f)
|
||||
.aspectRatio(16 / 9f)
|
||||
.drawWithCache {
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = ImmersiveBackgroundBrush(size))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(spacing.medium)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
style = TvMaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
val programme: Programme? by produceState<Programme?>(
|
||||
initialValue = null,
|
||||
key1 = channel.id
|
||||
) {
|
||||
value = currentGetProgrammeCurrently(channel.id)
|
||||
}
|
||||
|
||||
programme?.let {
|
||||
TvText(
|
||||
text = it.readText(),
|
||||
style = TvMaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TvLocalContentColor.current.copy(0.67f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier = Modifier.heightIn(min = maxBrowserHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.small),
|
||||
) {
|
||||
TvText(
|
||||
text = title,
|
||||
style = TvMaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Search,
|
||||
contentDescription = "search",
|
||||
onClick = openSearchDrawer
|
||||
)
|
||||
IconButton(
|
||||
icon = Icons.AutoMirrored.Rounded.Sort,
|
||||
contentDescription = "sort",
|
||||
onClick = openSortDrawer
|
||||
)
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "refresh",
|
||||
onClick = onRefresh
|
||||
)
|
||||
}
|
||||
SnackHost()
|
||||
}
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@ -50,7 +51,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import com.m3u.material.effects.BackStackEntry
|
||||
import com.m3u.material.effects.BackStackHandler
|
||||
import com.m3u.material.ktx.Edge
|
||||
@ -99,28 +100,37 @@ internal fun PlaylistTabRow(
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
icon = Icons.Rounded.PushPin,
|
||||
contentDescription = "pin",
|
||||
onClick = {
|
||||
name.let(onPinOrUnpinCategory)
|
||||
focusCategory = null
|
||||
}
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PushPin,
|
||||
contentDescription = "pin"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
icon = Icons.Rounded.VisibilityOff,
|
||||
contentDescription = "hide",
|
||||
onClick = {
|
||||
name.let(onHideCategory)
|
||||
focusCategory = null
|
||||
}
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.VisibilityOff,
|
||||
contentDescription = "hide"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Menu,
|
||||
contentDescription = "",
|
||||
onClick = onExpanded
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Menu,
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
package com.m3u.feature.playlist.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.feature.playlist.PlaylistViewModel
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
internal fun TvChannelGallery(
|
||||
categoryWithChannels: List<PlaylistViewModel.CategoryWithChannels>,
|
||||
maxBrowserHeight: Dp,
|
||||
isSpecifiedSort: Boolean,
|
||||
isVodOrSeriesPlaylist: Boolean,
|
||||
onClick: (Channel) -> Unit,
|
||||
onLongClick: (Channel) -> Unit,
|
||||
onFocus: (Channel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val multiCategories = categoryWithChannels.size > 1
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium),
|
||||
contentPadding = PaddingValues(vertical = spacing.medium),
|
||||
modifier = Modifier
|
||||
.heightIn(max = maxBrowserHeight)
|
||||
.fillMaxWidth()
|
||||
.then(modifier)
|
||||
) {
|
||||
items(categoryWithChannels) { (category, flow) ->
|
||||
val channels = flow.collectAsLazyPagingItems()
|
||||
if (multiCategories && channels.itemCount > 0) {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(spacing.medium)
|
||||
)
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.medium),
|
||||
contentPadding = PaddingValues(horizontal = spacing.medium),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(channels.itemCount) { index ->
|
||||
val channel = channels[index]
|
||||
if (channel != null) {
|
||||
TvChannelItem(
|
||||
channel = channel,
|
||||
isVodOrSeriesPlaylist = isVodOrSeriesPlaylist,
|
||||
isGridLayout = false,
|
||||
onClick = { onClick(channel) },
|
||||
onLongClick = { onLongClick(channel) },
|
||||
modifier = Modifier.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
onFocus(channel)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// TODO: placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
package com.m3u.feature.playlist.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.BrokenImage
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border as TvBorder
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.Glow as TvGlow
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import coil.size.Size as CoilSize
|
||||
|
||||
@Composable
|
||||
internal fun TvChannelItem(
|
||||
channel: Channel,
|
||||
isVodOrSeriesPlaylist: Boolean,
|
||||
isGridLayout: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val preferences = hiltPreferences()
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
val noPictureMode = preferences.noPictureMode
|
||||
val isCoverExisted = !channel.cover.isNullOrEmpty()
|
||||
|
||||
TvCard(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
glow = TvCardDefaults.glow(
|
||||
TvGlow(
|
||||
elevationColor = Color.Transparent,
|
||||
elevation = spacing.small
|
||||
)
|
||||
),
|
||||
scale = TvCardDefaults.scale(
|
||||
scale = 0.95f,
|
||||
focusedScale = 1.1f
|
||||
),
|
||||
border = TvCardDefaults.border(
|
||||
if (channel.favourite) TvBorder(
|
||||
BorderStroke(3.dp, TvMaterialTheme.colorScheme.border),
|
||||
)
|
||||
else TvBorder.None
|
||||
),
|
||||
modifier = Modifier
|
||||
.thenIf(!noPictureMode) {
|
||||
if (isGridLayout) Modifier.width(128.dp)
|
||||
else Modifier.height(128.dp)
|
||||
}
|
||||
.then(modifier)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (!isCoverExisted || noPictureMode) {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.widthIn(86.dp)
|
||||
.padding(spacing.medium),
|
||||
maxLines = 1
|
||||
)
|
||||
} else {
|
||||
SubcomposeAsyncImage(
|
||||
model = remember(channel.cover) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(channel.cover)
|
||||
.size(CoilSize.ORIGINAL)
|
||||
.build()
|
||||
},
|
||||
contentScale = if (isGridLayout) ContentScale.FillWidth
|
||||
else ContentScale.FillHeight,
|
||||
contentDescription = channel.title,
|
||||
loading = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(spacing.medium)
|
||||
) {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
maxLines = 1
|
||||
)
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
error = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(spacing.medium)
|
||||
) {
|
||||
TvText(
|
||||
text = channel.title,
|
||||
maxLines = 1
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.BrokenImage,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.then(
|
||||
if (isGridLayout) Modifier.fillMaxWidth()
|
||||
else Modifier.fillMaxHeight()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,318 +0,0 @@
|
||||
@file:Suppress("UsingMaterialAndMaterial3Libraries")
|
||||
|
||||
package com.m3u.feature.playlist.internal
|
||||
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.content.res.Configuration.ORIENTATION_PORTRAIT
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.material.BackdropScaffold
|
||||
import androidx.compose.material.BackdropValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.rememberBackdropScaffoldState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
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.Programme
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.feature.playlist.PlaylistViewModel
|
||||
import com.m3u.feature.playlist.components.PlaylistTabRow
|
||||
import com.m3u.feature.playlist.components.SmartphoneChannelGallery
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.TextField
|
||||
import com.m3u.material.ktx.isAtTop
|
||||
import com.m3u.material.ktx.only
|
||||
import com.m3u.material.ktx.split
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.EventHandler
|
||||
import com.m3u.ui.MediaSheet
|
||||
import com.m3u.ui.MediaSheetValue
|
||||
import com.m3u.ui.Sort
|
||||
import com.m3u.ui.SortBottomSheet
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.Metadata
|
||||
import dev.chrisbanes.haze.HazeDefaults
|
||||
import dev.chrisbanes.haze.haze
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@InternalComposeApi
|
||||
internal fun SmartphonePlaylistScreenImpl(
|
||||
categoryWithChannels: List<PlaylistViewModel.CategoryWithChannels>,
|
||||
pinnedCategories: List<String>,
|
||||
onPinOrUnpinCategory: (String) -> Unit,
|
||||
onHideCategory: (String) -> Unit,
|
||||
zapping: Channel?,
|
||||
query: String,
|
||||
onQuery: (String) -> Unit,
|
||||
rowCount: Int,
|
||||
scrollUp: Event<Unit>,
|
||||
sorts: List<Sort>,
|
||||
sort: Sort,
|
||||
onSort: (Sort) -> Unit,
|
||||
onPlayChannel: (Channel) -> Unit,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
favourite: (channelId: Int) -> Unit,
|
||||
onHide: (channelId: Int) -> Unit,
|
||||
onSaveCover: (channelId: Int) -> Unit,
|
||||
onCreateShortcut: (channelId: Int) -> Unit,
|
||||
isAtTopState: MutableState<Boolean>,
|
||||
isVodOrSeriesPlaylist: Boolean,
|
||||
getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues()
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val scaffoldState = rememberBackdropScaffoldState(
|
||||
initialValue = BackdropValue.Concealed
|
||||
)
|
||||
val connection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
return if (scaffoldState.isRevealed) available
|
||||
else Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
val currentColor = MaterialTheme.colorScheme.background
|
||||
val currentContentColor = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
var mediaSheetValue: MediaSheetValue.PlaylistScreen by remember { mutableStateOf(MediaSheetValue.PlaylistScreen()) }
|
||||
var isSortSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LifecycleResumeEffect(refreshing) {
|
||||
Metadata.actions = buildList {
|
||||
Action(
|
||||
icon = Icons.AutoMirrored.Rounded.Sort,
|
||||
contentDescription = "sort",
|
||||
onClick = { isSortSheetVisible = true }
|
||||
).also { add(it) }
|
||||
Action(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
enabled = !refreshing,
|
||||
contentDescription = "refresh",
|
||||
onClick = onRefresh
|
||||
).also { add(it) }
|
||||
}
|
||||
onPauseOrDispose {
|
||||
Metadata.actions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val categories = remember(categoryWithChannels) { categoryWithChannels.map { it.category } }
|
||||
var category by remember(categories) { mutableStateOf(categories.firstOrNull().orEmpty()) }
|
||||
|
||||
val (inner, outer) = contentPadding split WindowInsetsSides.Bottom
|
||||
|
||||
BackdropScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
gesturesEnabled = isAtTopState.value,
|
||||
appBar = {},
|
||||
frontLayerShape = RectangleShape,
|
||||
peekHeight = 0.dp,
|
||||
backLayerContent = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(scaffoldState.currentValue) {
|
||||
if (scaffoldState.isConcealed) {
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
BackHandler(scaffoldState.isRevealed || query.isNotEmpty()) {
|
||||
if (scaffoldState.isRevealed) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.conceal()
|
||||
}
|
||||
}
|
||||
if (query.isNotEmpty()) {
|
||||
onQuery("")
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextField(
|
||||
text = query,
|
||||
onValueChange = onQuery,
|
||||
fontWeight = FontWeight.Bold,
|
||||
placeholder = stringResource(string.feat_playlist_query_placeholder).uppercase(),
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(max = 48.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
frontLayerContent = {
|
||||
val state = rememberLazyStaggeredGridState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { state.isAtTop }
|
||||
.onEach { isAtTopState.value = it }
|
||||
.launchIn(this)
|
||||
}
|
||||
EventHandler(scrollUp) {
|
||||
state.scrollToItem(0)
|
||||
}
|
||||
val orientation = configuration.orientation
|
||||
val actualRowCount = remember(orientation, rowCount) {
|
||||
when (orientation) {
|
||||
ORIENTATION_LANDSCAPE -> rowCount + 2
|
||||
ORIENTATION_PORTRAIT -> rowCount
|
||||
else -> rowCount
|
||||
}
|
||||
}
|
||||
var isExpanded by remember(sort == Sort.MIXED) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
BackHandler(isExpanded) { isExpanded = false }
|
||||
|
||||
val tabs = @Composable {
|
||||
PlaylistTabRow(
|
||||
selectedCategory = category,
|
||||
categories = categories,
|
||||
isExpanded = isExpanded,
|
||||
bottomContentPadding = contentPadding only WindowInsetsSides.Bottom,
|
||||
onExpanded = { isExpanded = !isExpanded },
|
||||
onCategoryChanged = { category = it },
|
||||
pinnedCategories = pinnedCategories,
|
||||
onPinOrUnpinCategory = onPinOrUnpinCategory,
|
||||
onHideCategory = onHideCategory
|
||||
)
|
||||
}
|
||||
|
||||
val gallery = @Composable {
|
||||
val channel = remember(categoryWithChannels, category) {
|
||||
categoryWithChannels.find { it.category == category }
|
||||
}
|
||||
SmartphoneChannelGallery(
|
||||
state = state,
|
||||
rowCount = actualRowCount,
|
||||
categoryWithChannels = channel,
|
||||
zapping = zapping,
|
||||
recently = sort == Sort.RECENTLY,
|
||||
isVodOrSeriesPlaylist = isVodOrSeriesPlaylist,
|
||||
onClick = onPlayChannel,
|
||||
contentPadding = inner,
|
||||
onLongClick = {
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen(it)
|
||||
},
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
modifier = Modifier.haze(
|
||||
LocalHazeState.current,
|
||||
HazeDefaults.style(MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
) {
|
||||
if (!isExpanded) {
|
||||
AnimatedVisibility(
|
||||
visible = categories.size > 1,
|
||||
enter = fadeIn(animationSpec = tween(400))
|
||||
) {
|
||||
tabs()
|
||||
}
|
||||
gallery()
|
||||
} else {
|
||||
AnimatedVisibility(
|
||||
visible = categories.size > 1,
|
||||
enter = fadeIn(animationSpec = tween(400))
|
||||
) {
|
||||
tabs()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backLayerBackgroundColor = Color.Transparent,
|
||||
backLayerContentColor = currentContentColor,
|
||||
frontLayerScrimColor = currentColor.copy(alpha = 0.45f),
|
||||
frontLayerBackgroundColor = Color.Transparent,
|
||||
modifier = modifier
|
||||
.padding(outer)
|
||||
.nestedScroll(
|
||||
connection = connection
|
||||
)
|
||||
)
|
||||
|
||||
SortBottomSheet(
|
||||
visible = isSortSheetVisible,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
sheetState = sheetState,
|
||||
onChanged = onSort,
|
||||
onDismissRequest = { isSortSheetVisible = false }
|
||||
)
|
||||
|
||||
MediaSheet(
|
||||
value = mediaSheetValue,
|
||||
onFavouriteChannel = { channel ->
|
||||
favourite(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onHideChannel = { channel ->
|
||||
onHide(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onSaveChannelCover = { channel ->
|
||||
onSaveCover(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onCreateShortcut = { channel ->
|
||||
onCreateShortcut(channel.id)
|
||||
mediaSheetValue = MediaSheetValue.PlaylistScreen()
|
||||
},
|
||||
onDismissRequest = { mediaSheetValue = MediaSheetValue.PlaylistScreen() }
|
||||
)
|
||||
}
|
@ -1,307 +0,0 @@
|
||||
package com.m3u.feature.playlist.internal
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.selection.selectableGroup
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Shortcut
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Favorite
|
||||
import androidx.compose.material.icons.rounded.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.DenseListItem
|
||||
import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.data.database.model.Programme
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.feature.playlist.PlaylistViewModel
|
||||
import com.m3u.feature.playlist.components.ImmersiveBackground
|
||||
import com.m3u.feature.playlist.components.TvChannelGallery
|
||||
import com.m3u.i18n.R
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.components.tv.dialogFocusable
|
||||
import com.m3u.material.ktx.Edge
|
||||
import com.m3u.material.ktx.blurEdge
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.ui.Sort
|
||||
import com.m3u.ui.TvSortFullScreenDialog
|
||||
import dev.chrisbanes.haze.HazeDefaults
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import androidx.tv.material3.ListItemDefaults as TvListItemDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
@InternalComposeApi
|
||||
internal fun TvPlaylistScreenImpl(
|
||||
title: String,
|
||||
categoryWithChannels: List<PlaylistViewModel.CategoryWithChannels>,
|
||||
query: String,
|
||||
onQuery: (String) -> Unit,
|
||||
sorts: List<Sort>,
|
||||
sort: Sort,
|
||||
onSort: (Sort) -> Unit,
|
||||
favorite: (channelId: Int) -> Unit,
|
||||
hide: (channelId: Int) -> Unit,
|
||||
savePicture: (channelId: Int) -> Unit,
|
||||
createTvRecommend: (channelId: Int) -> Unit,
|
||||
onPlayChannel: (Channel) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
getProgrammeCurrently: suspend (channelId: Int) -> Programme?,
|
||||
isVodOrSeriesPlaylist: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val preferences = hiltPreferences()
|
||||
val multiCategories = categoryWithChannels.size > 1
|
||||
val noPictureMode = preferences.noPictureMode
|
||||
val useGridLayout = sort != Sort.UNSPECIFIED
|
||||
|
||||
val maxBrowserHeight by animateDpAsState(
|
||||
targetValue = when {
|
||||
useGridLayout || isVodOrSeriesPlaylist -> 360.dp
|
||||
noPictureMode -> 320.dp
|
||||
multiCategories -> 256.dp
|
||||
else -> 180.dp
|
||||
},
|
||||
label = "max-browser-height"
|
||||
)
|
||||
|
||||
var isSortSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var press: Channel? by remember { mutableStateOf(null) }
|
||||
var focus: Channel? by remember { mutableStateOf(null) }
|
||||
|
||||
val content = @Composable {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
ImmersiveBackground(
|
||||
title = title,
|
||||
channel = focus,
|
||||
maxBrowserHeight = maxBrowserHeight,
|
||||
onRefresh = onRefresh,
|
||||
openSearchDrawer = {},
|
||||
openSortDrawer = { isSortSheetVisible = true },
|
||||
getProgrammeCurrently = getProgrammeCurrently,
|
||||
modifier = Modifier.haze(
|
||||
LocalHazeState.current,
|
||||
HazeDefaults.style(TvMaterialTheme.colorScheme.background)
|
||||
)
|
||||
)
|
||||
TvChannelGallery(
|
||||
categoryWithChannels = categoryWithChannels,
|
||||
maxBrowserHeight = maxBrowserHeight,
|
||||
isSpecifiedSort = useGridLayout,
|
||||
isVodOrSeriesPlaylist = isVodOrSeriesPlaylist,
|
||||
onClick = onPlayChannel,
|
||||
onLongClick = { channel -> press = channel },
|
||||
onFocus = { channel -> focus = channel },
|
||||
modifier = Modifier
|
||||
.hazeChild(
|
||||
LocalHazeState.current,
|
||||
style = HazeStyle(blurRadius = 4.dp)
|
||||
)
|
||||
.blurEdge(
|
||||
color = TvMaterialTheme.colorScheme.background,
|
||||
edge = Edge.Top
|
||||
)
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Background {
|
||||
content()
|
||||
MenuFullScreenDialog(
|
||||
channel = press,
|
||||
favorite = favorite,
|
||||
hide = hide,
|
||||
savePicture = savePicture,
|
||||
createShortcutOrTvRecommend = createTvRecommend,
|
||||
onDismissRequest = { press = null }
|
||||
)
|
||||
TvSortFullScreenDialog(
|
||||
visible = isSortSheetVisible,
|
||||
sort = sort,
|
||||
sorts = sorts,
|
||||
onChanged = { onSort(it) },
|
||||
onDismissRequest = { isSortSheetVisible = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuFullScreenDialog(
|
||||
channel: Channel?,
|
||||
favorite: (channelId: Int) -> Unit,
|
||||
hide: (channelId: Int) -> Unit,
|
||||
savePicture: (channelId: Int) -> Unit,
|
||||
createShortcutOrTvRecommend: (channelId: Int) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val favouriteTitle = stringResource(
|
||||
if (channel?.favourite == true) R.string.feat_playlist_dialog_favourite_cancel_title
|
||||
else R.string.feat_playlist_dialog_favourite_title
|
||||
).uppercase()
|
||||
val hideTitle = stringResource(R.string.feat_playlist_dialog_hide_title).uppercase()
|
||||
val createShortcutTitle =
|
||||
stringResource(R.string.feat_playlist_dialog_create_shortcut_title).uppercase()
|
||||
val savePictureTitle =
|
||||
stringResource(R.string.feat_playlist_dialog_save_picture_title).uppercase()
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier)
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = channel != null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.4f)
|
||||
.align(Alignment.CenterEnd)
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.background(TvMaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(12.dp)
|
||||
.selectableGroup()
|
||||
.dialogFocusable(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item {
|
||||
DenseListItem(
|
||||
selected = false,
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = channel?.title.orEmpty(),
|
||||
maxLines = 1,
|
||||
style = TvMaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
item {
|
||||
DenseListItem(
|
||||
selected = false,
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = favouriteTitle
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
channel?.let { channel ->
|
||||
favorite(channel.id)
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Favorite,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
scale = TvListItemDefaults.scale(0.95f, 1f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
DenseListItem(
|
||||
selected = false,
|
||||
onClick = {
|
||||
channel?.let {
|
||||
hide(it.id)
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = hideTitle
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
scale = TvListItemDefaults.scale(0.95f, 1f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
DenseListItem(
|
||||
selected = false,
|
||||
onClick = {
|
||||
channel?.let {
|
||||
createShortcutOrTvRecommend(it.id)
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = createShortcutTitle
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.Shortcut,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
scale = TvListItemDefaults.scale(0.95f, 1f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
DenseListItem(
|
||||
selected = false,
|
||||
onClick = {
|
||||
channel?.let {
|
||||
savePicture(it.id)
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
TvText(
|
||||
text = savePictureTitle
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Image,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
scale = TvListItemDefaults.scale(0.95f, 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,14 +10,11 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.activity
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.m3u.feature.playlist.PlaylistRoute
|
||||
import com.m3u.feature.playlist.TvPlaylistActivity
|
||||
|
||||
private const val PLAYLIST_ROUTE_PATH = "playlist_route"
|
||||
private const val PLAYLIST_TV_ROUTE_PATH = "playlist_tv_route"
|
||||
|
||||
object PlaylistNavigation {
|
||||
internal const val TYPE_URL = "url"
|
||||
@ -28,23 +25,14 @@ object PlaylistNavigation {
|
||||
internal fun createPlaylistRoute(url: String): String {
|
||||
return "$PLAYLIST_ROUTE_PATH?$TYPE_URL=$url"
|
||||
}
|
||||
|
||||
internal const val PLAYLIST_TV_ROUTE =
|
||||
"$PLAYLIST_TV_ROUTE_PATH?$TYPE_URL={$TYPE_URL}"
|
||||
|
||||
internal fun createPlaylistTvRoute(url: String): String {
|
||||
return "$PLAYLIST_TV_ROUTE_PATH?${TYPE_URL}=$url"
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToPlaylist(
|
||||
playlistUrl: String,
|
||||
tv: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
val encodedUrl = Uri.encode(playlistUrl)
|
||||
val route = if (tv) PlaylistNavigation.createPlaylistTvRoute(encodedUrl)
|
||||
else PlaylistNavigation.createPlaylistRoute(encodedUrl)
|
||||
val route = PlaylistNavigation.createPlaylistRoute(encodedUrl)
|
||||
this.navigate(route, navOptions)
|
||||
}
|
||||
|
||||
@ -70,12 +58,3 @@ fun NavGraphBuilder.playlistScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.playlistTvScreen() {
|
||||
activity(PlaylistNavigation.PLAYLIST_TV_ROUTE) {
|
||||
activityClass = TvPlaylistActivity::class
|
||||
argument(PlaylistNavigation.TYPE_URL) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -41,8 +41,6 @@ import com.m3u.feature.setting.fragments.OptionalFragment
|
||||
import com.m3u.feature.setting.fragments.SubscriptionsFragment
|
||||
import com.m3u.feature.setting.fragments.preferences.PreferencesFragment
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.ktx.includeChildGlowPadding
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.EventHandler
|
||||
@ -59,7 +57,6 @@ fun SettingRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingViewModel = hiltViewModel()
|
||||
) {
|
||||
val tv = tv()
|
||||
val controller = LocalSoftwareKeyboardController.current
|
||||
|
||||
val colorSchemes by viewModel.colorSchemes.collectAsStateWithLifecycle()
|
||||
@ -129,18 +126,16 @@ fun SettingRoute(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding
|
||||
)
|
||||
if (!tv) {
|
||||
CanvasBottomSheet(
|
||||
sheetState = sheetState,
|
||||
colorScheme = colorScheme,
|
||||
onApplyColor = { argb, isDark ->
|
||||
viewModel.applyColor(colorScheme, argb, isDark)
|
||||
},
|
||||
onDismissRequest = {
|
||||
colorScheme = null
|
||||
}
|
||||
)
|
||||
}
|
||||
CanvasBottomSheet(
|
||||
sheetState = sheetState,
|
||||
colorScheme = colorScheme,
|
||||
onApplyColor = { argb, isDark ->
|
||||
viewModel.applyColor(colorScheme, argb, isDark)
|
||||
},
|
||||
onDismissRequest = {
|
||||
colorScheme = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -249,7 +244,6 @@ private fun SettingScreen(
|
||||
onClearCache = onClearCache,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.includeChildGlowPadding()
|
||||
)
|
||||
},
|
||||
detailPane = {
|
||||
|
@ -38,7 +38,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.data.database.model.ColorScheme
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.ktx.createScheme
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.model.SugarColors
|
||||
|
@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.data.database.model.DataSource
|
||||
import com.m3u.material.components.ClickableSelection
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.SelectionsDefaults
|
||||
|
||||
@Composable
|
||||
|
@ -2,6 +2,7 @@ package com.m3u.feature.setting.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@ -9,7 +10,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.m3u.data.database.model.Playlist
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
|
||||
@Composable
|
||||
internal fun EpgPlaylistItem(
|
||||
@ -34,10 +35,13 @@ internal fun EpgPlaylistItem(
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
onClick = onDeleteEpgPlaylist,
|
||||
contentDescription = "delete epg"
|
||||
)
|
||||
onClick = onDeleteEpgPlaylist
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "delete epg"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
|
@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.m3u.core.util.readFileName
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.ToggleableSelection
|
||||
|
||||
@Composable
|
||||
|
@ -39,7 +39,6 @@ import com.m3u.material.components.Preference
|
||||
import com.m3u.material.components.TextPreference
|
||||
import com.m3u.material.components.ThemeAddSelection
|
||||
import com.m3u.material.components.ThemeSelection
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.minus
|
||||
import com.m3u.material.ktx.only
|
||||
import com.m3u.material.ktx.plus
|
||||
@ -60,7 +59,6 @@ internal fun AppearanceFragment(
|
||||
|
||||
val isDarkMode = preferences.darkMode
|
||||
val useDynamicColors = preferences.useDynamicColors
|
||||
val tv = tv()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@ -181,17 +179,15 @@ internal fun AppearanceFragment(
|
||||
enabled = useDynamicColorsAvailable
|
||||
)
|
||||
}
|
||||
if (!tv) {
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_colorful_background,
|
||||
icon = Icons.Rounded.Stars,
|
||||
checked = preferences.colorfulBackground,
|
||||
onChanged = {
|
||||
preferences.colorfulBackground = !preferences.colorfulBackground
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_colorful_background,
|
||||
icon = Icons.Rounded.Stars,
|
||||
checked = preferences.colorfulBackground,
|
||||
onChanged = {
|
||||
preferences.colorfulBackground = !preferences.colorfulBackground
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
Preference(
|
||||
@ -201,15 +197,13 @@ internal fun AppearanceFragment(
|
||||
)
|
||||
}
|
||||
item {
|
||||
if (!tv) {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_god_mode,
|
||||
content = string.feat_setting_god_mode_description,
|
||||
icon = Icons.Rounded.DeviceHub,
|
||||
checked = preferences.godMode,
|
||||
onChanged = { preferences.godMode = !preferences.godMode }
|
||||
)
|
||||
}
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_god_mode,
|
||||
content = string.feat_setting_god_mode_description,
|
||||
icon = Icons.Rounded.DeviceHub,
|
||||
checked = preferences.godMode,
|
||||
onChanged = { preferences.godMode = !preferences.godMode }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,8 +37,6 @@ import com.m3u.core.util.basic.title
|
||||
import com.m3u.feature.setting.components.SwitchSharedPreference
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.TextPreference
|
||||
import com.m3u.material.ktx.includeChildGlowPadding
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.plus
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import kotlin.time.DurationUnit
|
||||
@ -51,13 +49,11 @@ internal fun OptionalFragment(
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val preferences = hiltPreferences()
|
||||
val tv = tv()
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.small),
|
||||
contentPadding = contentPadding + PaddingValues(horizontal = spacing.medium),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.includeChildGlowPadding()
|
||||
) {
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
@ -86,32 +82,30 @@ internal fun OptionalFragment(
|
||||
)
|
||||
}
|
||||
|
||||
if (!tv) {
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_zapping_mode,
|
||||
content = string.feat_setting_zapping_mode_description,
|
||||
icon = Icons.Rounded.PictureInPicture,
|
||||
checked = preferences.zappingMode,
|
||||
onChanged = { preferences.zappingMode = !preferences.zappingMode }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_gesture_brightness,
|
||||
icon = Icons.Rounded.BrightnessMedium,
|
||||
checked = preferences.brightnessGesture,
|
||||
onChanged = { preferences.brightnessGesture = !preferences.brightnessGesture }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_gesture_volume,
|
||||
icon = Icons.AutoMirrored.Rounded.VolumeUp,
|
||||
checked = preferences.volumeGesture,
|
||||
onChanged = { preferences.volumeGesture = !preferences.volumeGesture }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_zapping_mode,
|
||||
content = string.feat_setting_zapping_mode_description,
|
||||
icon = Icons.Rounded.PictureInPicture,
|
||||
checked = preferences.zappingMode,
|
||||
onChanged = { preferences.zappingMode = !preferences.zappingMode }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_gesture_brightness,
|
||||
icon = Icons.Rounded.BrightnessMedium,
|
||||
checked = preferences.brightnessGesture,
|
||||
onChanged = { preferences.brightnessGesture = !preferences.brightnessGesture }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_gesture_volume,
|
||||
icon = Icons.AutoMirrored.Rounded.VolumeUp,
|
||||
checked = preferences.volumeGesture,
|
||||
onChanged = { preferences.volumeGesture = !preferences.volumeGesture }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
@ -140,24 +134,22 @@ internal fun OptionalFragment(
|
||||
onChanged = { preferences.cache = !preferences.cache }
|
||||
)
|
||||
}
|
||||
if (!tv) {
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_screen_rotating,
|
||||
content = string.feat_setting_screen_rotating_description,
|
||||
icon = Icons.Rounded.ScreenRotation,
|
||||
checked = preferences.screenRotating,
|
||||
onChanged = { preferences.screenRotating = !preferences.screenRotating }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_screencast,
|
||||
icon = Icons.Rounded.Cast,
|
||||
checked = preferences.screencast,
|
||||
onChanged = { preferences.screencast = !preferences.screencast }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_screen_rotating,
|
||||
content = string.feat_setting_screen_rotating_description,
|
||||
icon = Icons.Rounded.ScreenRotation,
|
||||
checked = preferences.screenRotating,
|
||||
onChanged = { preferences.screenRotating = !preferences.screenRotating }
|
||||
)
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = string.feat_setting_screencast,
|
||||
icon = Icons.Rounded.Cast,
|
||||
checked = preferences.screencast,
|
||||
onChanged = { preferences.screencast = !preferences.screencast }
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextPreference(
|
||||
@ -261,10 +253,8 @@ internal fun OptionalFragment(
|
||||
}
|
||||
item {
|
||||
SwitchSharedPreference(
|
||||
title = if (!tv) string.feat_setting_remote_control
|
||||
else string.feat_setting_remote_control_tv_side,
|
||||
content = if (!tv) string.feat_setting_remote_control_description
|
||||
else string.feat_setting_remote_control_tv_side_description,
|
||||
title = string.feat_setting_remote_control,
|
||||
content = string.feat_setting_remote_control_description,
|
||||
icon = Icons.Rounded.SettingsRemote,
|
||||
checked = preferences.remoteControl,
|
||||
onChanged = { preferences.remoteControl = !preferences.remoteControl }
|
||||
|
@ -20,6 +20,8 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@ -44,13 +46,10 @@ import com.m3u.feature.setting.components.LocalStorageButton
|
||||
import com.m3u.feature.setting.components.LocalStorageSwitch
|
||||
import com.m3u.feature.setting.components.RemoteControlSubscribeSwitch
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.Button
|
||||
import com.m3u.material.components.HorizontalPagerIndicator
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.components.PlaceholderField
|
||||
import com.m3u.material.components.TonalButton
|
||||
import com.m3u.material.ktx.checkPermissionOrRationale
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.textHorizontalLabel
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.helper.LocalHelper
|
||||
@ -172,7 +171,6 @@ private fun MainContentImpl(
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val helper = LocalHelper.current
|
||||
|
||||
val tv = tv()
|
||||
val remoteControl = preferences.remoteControl
|
||||
|
||||
LazyColumn(
|
||||
@ -233,7 +231,7 @@ private fun MainContentImpl(
|
||||
enabled = !forTvState.value
|
||||
)
|
||||
}
|
||||
if (!tv && remoteControl) {
|
||||
if (remoteControl) {
|
||||
RemoteControlSubscribeSwitch(
|
||||
checked = forTvState.value,
|
||||
onChanged = { forTvState.value = !forTvState.value },
|
||||
@ -247,7 +245,6 @@ private fun MainContentImpl(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
Button(
|
||||
text = stringResource(string.feat_setting_label_subscribe),
|
||||
onClick = {
|
||||
postNotificationPermission.checkPermissionOrRationale(
|
||||
showRationale = {
|
||||
@ -266,17 +263,20 @@ private fun MainContentImpl(
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
) {
|
||||
Text(stringResource(string.feat_setting_label_subscribe))
|
||||
}
|
||||
when (selectedState.value) {
|
||||
DataSource.M3U, DataSource.Xtream -> {
|
||||
TonalButton(
|
||||
text = stringResource(string.feat_setting_label_parse_from_clipboard),
|
||||
FilledTonalButton(
|
||||
enabled = !localStorageState.value,
|
||||
onClick = {
|
||||
onClipboard(clipboardManager.getText()?.text.orEmpty())
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
) {
|
||||
Text(stringResource(string.feat_setting_label_parse_from_clipboard))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
@ -284,18 +284,22 @@ private fun MainContentImpl(
|
||||
}
|
||||
|
||||
item {
|
||||
TonalButton(
|
||||
text = stringResource(string.feat_setting_label_backup),
|
||||
FilledTonalButton(
|
||||
enabled = !forTvState.value && backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
onClick = backup,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
TonalButton(
|
||||
text = stringResource(string.feat_setting_label_restore),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(string.feat_setting_label_backup)
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
enabled = !forTvState.value && backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
onClick = restore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(string.feat_setting_label_restore))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
|
@ -55,6 +55,7 @@ ktor-server = "3.0.0-beta-1"
|
||||
mm2d-mmupnp = "3.1.6"
|
||||
symbolProcessingApi = "2.0.0-1.0.22"
|
||||
profileinstaller = "1.4.1"
|
||||
tvFoundation = "1.0.0-alpha12"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
|
||||
@ -168,6 +169,11 @@ net-mm2d-mmupnp-mmupnp = { group = "net.mm2d.mmupnp", name = "mmupnp", version.r
|
||||
androidx-graphics-shapes-android = { group = "androidx.graphics", name = "graphics-shapes-android", version.ref = "androidx-graphics-shapes" }
|
||||
symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "symbolProcessingApi" }
|
||||
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
||||
|
||||
[plugins]
|
||||
com-android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
|
||||
|
@ -11,6 +11,6 @@
|
||||
<string name="feat_foryou_recommend_unseen_days">%d days</string>
|
||||
<string name="feat_foryou_recommend_unseen_hours">%d hours</string>
|
||||
<string name="feat_foryou_new_release">new release</string>
|
||||
<string name="feat_foryou_connect_title">enter code from tv</string>
|
||||
<string name="feat_foryou_connect_title">enter code from TV</string>
|
||||
<string name="feat_foryou_connect_subtitle">Make sure to connect to the same Wi-Fi</string>
|
||||
</resources>
|
@ -42,8 +42,6 @@ dependencies {
|
||||
|
||||
implementation(libs.airbnb.lottie.compose)
|
||||
|
||||
api(libs.androidx.tv.material)
|
||||
|
||||
api(libs.androidx.graphics.shapes.android)
|
||||
api(libs.google.material)
|
||||
api(libs.haze)
|
||||
|
@ -14,9 +14,6 @@ import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.material.ktx.tv
|
||||
import androidx.tv.material3.LocalContentColor as TvLocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme as TvMtaterialTheme
|
||||
|
||||
@Composable
|
||||
inline fun Background(
|
||||
@ -26,14 +23,8 @@ inline fun Background(
|
||||
shape: Shape = RectangleShape,
|
||||
crossinline content: @Composable () -> Unit
|
||||
) {
|
||||
val actualColor = color.takeOrElse {
|
||||
if (!tv()) MaterialTheme.colorScheme.background
|
||||
else TvMtaterialTheme.colorScheme.background
|
||||
}
|
||||
val actualContentColor = contentColor.takeOrElse {
|
||||
if (!tv()) MaterialTheme.colorScheme.onBackground
|
||||
else TvMtaterialTheme.colorScheme.onBackground
|
||||
}
|
||||
val actualColor = color.takeOrElse { MaterialTheme.colorScheme.background }
|
||||
val actualContentColor = contentColor.takeOrElse { MaterialTheme.colorScheme.onBackground }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
@ -44,8 +35,7 @@ inline fun Background(
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAbsoluteTonalElevation provides 0.dp,
|
||||
LocalContentColor provides actualContentColor,
|
||||
TvLocalContentColor provides actualContentColor
|
||||
LocalContentColor provides actualContentColor
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
@ -1,263 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.m3u.material.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isUnspecified
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import androidx.tv.material3.Button as TvButton
|
||||
import androidx.tv.material3.OutlinedButton as TvOutlinedButton
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
fun Button(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledContainerColor: Color = containerColor.copy(alpha = 0.12f),
|
||||
disabledContentColor: Color = containerColor.copy(alpha = 0.38f),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
Button(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = disabledContainerColor,
|
||||
disabledContentColor = disabledContentColor
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.padding(spacing.extraSmall)
|
||||
.then(modifier),
|
||||
colors = androidx.tv.material3.ButtonDefaults.colors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = disabledContainerColor,
|
||||
disabledContentColor = disabledContentColor
|
||||
)
|
||||
) {
|
||||
TvText(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextButton(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
TextButton(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContentColor = disabledContentColor
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvOutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.padding(spacing.extraSmall)
|
||||
.then(modifier),
|
||||
colors = androidx.tv.material3.ButtonDefaults.colors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContentColor = disabledContentColor
|
||||
)
|
||||
) {
|
||||
TvText(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TonalButton(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
FilledTonalButton(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
elevation = ButtonDefaults.filledTonalButtonElevation(spacing.none)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvOutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.padding(spacing.extraSmall)
|
||||
.then(modifier),
|
||||
) {
|
||||
TvText(
|
||||
text = text.uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
tint: Color = Color.Unspecified
|
||||
) {
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = if (tint.isUnspecified) IconButtonDefaults.iconButtonColors()
|
||||
else IconButtonDefaults.iconButtonColors(
|
||||
contentColor = tint
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
androidx.tv.material3.IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = if (tint.isUnspecified) androidx.tv.material3.IconButtonDefaults.colors()
|
||||
else androidx.tv.material3.IconButtonDefaults.colors(
|
||||
contentColor = tint,
|
||||
focusedContainerColor = tint,
|
||||
pressedContainerColor = tint
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BrushButton(
|
||||
text: String,
|
||||
brush: Brush,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(brush)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
onClick = onClick
|
||||
),
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
ProvideTextStyle(value = MaterialTheme.typography.titleSmall) {
|
||||
Row(
|
||||
Modifier
|
||||
.defaultMinSize(
|
||||
minWidth = ButtonDefaults.MinWidth,
|
||||
minHeight = ButtonDefaults.MinHeight
|
||||
)
|
||||
.padding(ButtonDefaults.ContentPadding),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
package com.m3u.material.components
|
||||
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.m3u.material.ktx.tv
|
||||
|
||||
@Composable
|
||||
fun Icon(
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Unspecified
|
||||
) {
|
||||
val tv = tv()
|
||||
if (!tv) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint.takeOrElse { LocalContentColor.current }
|
||||
)
|
||||
} else {
|
||||
androidx.tv.material3.Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint.takeOrElse { androidx.tv.material3.LocalContentColor.current }
|
||||
)
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LocalAbsoluteTonalElevation
|
||||
@ -33,16 +34,8 @@ import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
import androidx.tv.material3.Checkbox as TvCheckbox
|
||||
import androidx.tv.material3.Icon as TvIcon
|
||||
import androidx.tv.material3.ListItem as TvListItem
|
||||
import androidx.tv.material3.ListItemDefaults as TvListItemDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Switch as TvSwitch
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
fun Preference(
|
||||
@ -73,92 +66,53 @@ fun Preference(
|
||||
}
|
||||
) {
|
||||
val alpha = if (enabled) 1f else 0.38f
|
||||
if (!tv()) {
|
||||
OutlinedCard(
|
||||
colors = CardDefaults.outlinedCardColors(Color.Transparent),
|
||||
shape = AbsoluteSmoothCornerShape(spacing.medium, 65)
|
||||
) {
|
||||
ListItem(
|
||||
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 {
|
||||
@Composable {
|
||||
Icon(imageVector = it, contentDescription = null)
|
||||
}
|
||||
},
|
||||
tonalElevation = LocalAbsoluteTonalElevation.current,
|
||||
shadowElevation = elevation,
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
|
||||
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
|
||||
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha)
|
||||
),
|
||||
modifier = modifier
|
||||
.semantics(mergeDescendants = true) {}
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple()
|
||||
)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvListItem(
|
||||
selected = focus,
|
||||
interactionSource = interactionSource,
|
||||
OutlinedCard(
|
||||
colors = CardDefaults.outlinedCardColors(Color.Transparent),
|
||||
shape = AbsoluteSmoothCornerShape(spacing.medium, 65)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
TvText(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (content != null) {
|
||||
TvText(
|
||||
Text(
|
||||
text = content.capitalize(Locale.current),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier then if (focus) Modifier.basicMarquee()
|
||||
else Modifier
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = trailing,
|
||||
leadingContent = icon?.let {
|
||||
@Composable {
|
||||
TvIcon(imageVector = it, contentDescription = null)
|
||||
Icon(imageVector = it, contentDescription = null)
|
||||
}
|
||||
},
|
||||
scale = TvListItemDefaults.scale(
|
||||
scale = 0.95f,
|
||||
focusedScale = 1f
|
||||
tonalElevation = LocalAbsoluteTonalElevation.current,
|
||||
shadowElevation = elevation,
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
|
||||
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
|
||||
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha)
|
||||
),
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.semantics(mergeDescendants = true) {}
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple()
|
||||
)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@ -189,19 +143,11 @@ fun CheckBoxPreference(
|
||||
},
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
if (!tv()) {
|
||||
Checkbox(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
} else {
|
||||
TvCheckbox(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
icon = icon
|
||||
)
|
||||
@ -230,19 +176,11 @@ fun SwitchPreference(
|
||||
},
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
if (!tv()) {
|
||||
Switch(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
} else {
|
||||
TvSwitch(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
icon = icon
|
||||
)
|
||||
@ -267,18 +205,11 @@ fun TrailingIconPreference(
|
||||
elevation = elevation,
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
if (!tv()) {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
tint = LocalContentColor.current.copy(alpha = 0.65f)
|
||||
)
|
||||
} else {
|
||||
TvIcon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
tint = LocalContentColor.current.copy(alpha = 0.65f)
|
||||
)
|
||||
},
|
||||
icon = icon
|
||||
)
|
||||
@ -305,24 +236,14 @@ fun TextPreference(
|
||||
},
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
if (!tv()) {
|
||||
Text(
|
||||
text = trailing.uppercase(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
TvText(
|
||||
text = trailing.uppercase(),
|
||||
style = TvMaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = trailing.uppercase(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
icon = icon
|
||||
)
|
||||
|
@ -2,12 +2,9 @@
|
||||
|
||||
package com.m3u.material.components
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
@ -24,6 +21,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -31,20 +29,14 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@ -55,15 +47,8 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.Surface
|
||||
import com.m3u.material.ktx.InteractionType
|
||||
import com.m3u.material.ktx.interactionBorder
|
||||
import com.m3u.material.ktx.tv
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
fun TextField(
|
||||
@ -185,151 +170,15 @@ fun PlaceholderField(
|
||||
icon: ImageVector? = null,
|
||||
onValueChange: (String) -> Unit = {},
|
||||
) {
|
||||
if (!tv()) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focus by interactionSource.collectIsFocusedAsState()
|
||||
|
||||
BackHandler(focus) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
val fontSize = TextFieldDefaults.MinimizeLabelFontSize
|
||||
|
||||
val theme = MaterialTheme.colorScheme
|
||||
CompositionLocalProvider(
|
||||
LocalTextSelectionColors provides TextSelectionColors(
|
||||
handleColor = theme.primary,
|
||||
backgroundColor = theme.primary.copy(alpha = 0.45f)
|
||||
)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
singleLine = singleLine,
|
||||
enabled = enabled,
|
||||
textStyle = TextStyle(
|
||||
fontFamily = MaterialTheme.typography.bodyMedium.fontFamily,
|
||||
fontSize = fontSize,
|
||||
color = contentColor,
|
||||
fontWeight = fontWeight
|
||||
),
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
keyboardActions = keyboardActions ?: KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
onSearch = { focusManager.clearFocus() }
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = imeAction
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
readOnly = readOnly,
|
||||
cursorBrush = SolidColor(contentColor.copy(.35f)),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
icon?.let { icon ->
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.padding(15.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
.padding(
|
||||
start = if (icon == null) 15.dp else 0.dp,
|
||||
end = 15.dp
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
val hasText = text.isNotEmpty()
|
||||
|
||||
val animPlaceholder: Dp by animateDpAsState(
|
||||
if (focus || hasText) (-10).dp else 0.dp,
|
||||
label = "placeholder-translation-y"
|
||||
)
|
||||
val animPlaceHolderFontSize: Float by animateFloatAsState(
|
||||
targetValue = if (focus || hasText) 12f else 14f,
|
||||
label = "placeholder-font-size"
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
translationY = animPlaceholder.toPx()
|
||||
},
|
||||
text = placeholder,
|
||||
color = contentColor.copy(alpha = .35f),
|
||||
fontSize = animPlaceHolderFontSize.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = -animPlaceholder)
|
||||
.fillMaxWidth()
|
||||
.heightIn(18.dp),
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvTextFieldImpl(
|
||||
value = text,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = placeholder,
|
||||
shape = shape,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun TvTextFieldImpl(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RectangleShape,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isFocus by interactionSource.collectIsFocusedAsState()
|
||||
val focus by interactionSource.collectIsFocusedAsState()
|
||||
|
||||
BackHandler(focus) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
val fontSize = TextFieldDefaults.MinimizeLabelFontSize
|
||||
|
||||
val theme = MaterialTheme.colorScheme
|
||||
CompositionLocalProvider(
|
||||
@ -338,92 +187,107 @@ private fun TvTextFieldImpl(
|
||||
backgroundColor = theme.primary.copy(alpha = 0.45f)
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = ClickableSurfaceDefaults.shape(shape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = TvMaterialTheme.colorScheme.inverseOnSurface,
|
||||
focusedContainerColor = TvMaterialTheme.colorScheme.inverseOnSurface,
|
||||
pressedContainerColor = TvMaterialTheme.colorScheme.inverseOnSurface,
|
||||
focusedContentColor = TvMaterialTheme.colorScheme.onSurface,
|
||||
pressedContentColor = TvMaterialTheme.colorScheme.onSurface
|
||||
BasicTextField(
|
||||
value = text,
|
||||
singleLine = singleLine,
|
||||
enabled = enabled,
|
||||
textStyle = TextStyle(
|
||||
fontFamily = MaterialTheme.typography.bodyMedium.fontFamily,
|
||||
fontSize = fontSize,
|
||||
color = contentColor,
|
||||
fontWeight = fontWeight
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(
|
||||
width = if (isFocus) 2.dp else 1.dp,
|
||||
color = animateColorAsState(
|
||||
targetValue = if (isFocus) TvMaterialTheme.colorScheme.primary
|
||||
else TvMaterialTheme.colorScheme.border, label = ""
|
||||
).value
|
||||
),
|
||||
shape = shape
|
||||
)
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
keyboardActions = keyboardActions ?: KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
onSearch = { focusManager.clearFocus() }
|
||||
),
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier,
|
||||
onClick = { focusRequester.requestFocus() }
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
decorationBox = { innerTextField ->
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = imeAction
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
readOnly = readOnly,
|
||||
cursorBrush = SolidColor(contentColor.copy(.35f)),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
icon?.let { icon ->
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.padding(15.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
Modifier
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
.padding(
|
||||
start = if (icon == null) 15.dp else 0.dp,
|
||||
end = 15.dp
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
innerTextField()
|
||||
if (value.isEmpty()) {
|
||||
TvText(
|
||||
modifier = Modifier.graphicsLayer { alpha = 0.6f },
|
||||
text = placeholder,
|
||||
style = TvMaterialTheme.typography.titleSmall
|
||||
)
|
||||
val hasText = text.isNotEmpty()
|
||||
|
||||
val animPlaceholder: Dp by animateDpAsState(
|
||||
if (focus || hasText) (-10).dp else 0.dp,
|
||||
label = "placeholder-translation-y"
|
||||
)
|
||||
val animPlaceHolderFontSize: Float by animateFloatAsState(
|
||||
targetValue = if (focus || hasText) 12f else 14f,
|
||||
label = "placeholder-font-size"
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
translationY = animPlaceholder.toPx()
|
||||
},
|
||||
text = placeholder,
|
||||
color = contentColor.copy(alpha = .35f),
|
||||
fontSize = animPlaceHolderFontSize.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = -animPlaceholder)
|
||||
.fillMaxWidth()
|
||||
.heightIn(18.dp),
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onKeyEvent {
|
||||
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
|
||||
when (it.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
focusManager.moveFocus(FocusDirection.Up)
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
focusManager.moveFocus(FocusDirection.Exit)
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
cursorBrush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
LocalContentColor.current,
|
||||
LocalContentColor.current,
|
||||
)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
maxLines = 1,
|
||||
interactionSource = interactionSource,
|
||||
textStyle = TvMaterialTheme.typography.titleSmall.copy(
|
||||
color = TvMaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.DarkMode
|
||||
import androidx.compose.material.icons.rounded.LightMode
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
@ -43,17 +44,12 @@ 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.sp
|
||||
import androidx.tv.material3.Border
|
||||
import com.m3u.material.LocalM3UHapticFeedback
|
||||
import com.m3u.material.ktx.InteractionType
|
||||
import com.m3u.material.ktx.createScheme
|
||||
import com.m3u.material.ktx.interactionBorder
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.model.SugarColors
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.Icon as TvIcon
|
||||
|
||||
@Composable
|
||||
fun ThemeSelection(
|
||||
@ -68,7 +64,6 @@ fun ThemeSelection(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val tv = tv()
|
||||
|
||||
val colorScheme = remember(argb, isDark) {
|
||||
createScheme(argb, isDark)
|
||||
@ -144,113 +139,67 @@ fun ThemeSelection(
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!tv) {
|
||||
val zoom by animateFloatAsState(
|
||||
targetValue = if (selected) 0.95f else 0.85f,
|
||||
label = "zoom"
|
||||
)
|
||||
val corner by animateDpAsState(
|
||||
targetValue = if (!selected) spacing.extraLarge else spacing.medium,
|
||||
label = "corner"
|
||||
)
|
||||
val shape = RoundedCornerShape(corner)
|
||||
val zoom by animateFloatAsState(
|
||||
targetValue = if (selected) 0.95f else 0.85f,
|
||||
label = "zoom"
|
||||
)
|
||||
val corner by animateDpAsState(
|
||||
targetValue = if (!selected) spacing.extraLarge else spacing.medium,
|
||||
label = "corner"
|
||||
)
|
||||
val shape = RoundedCornerShape(corner)
|
||||
|
||||
OutlinedCard(
|
||||
shape = shape,
|
||||
colors = CardDefaults.outlinedCardColors(
|
||||
containerColor = colorScheme.background,
|
||||
contentColor = colorScheme.onBackground
|
||||
),
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = zoom
|
||||
scaleY = zoom
|
||||
OutlinedCard(
|
||||
shape = shape,
|
||||
colors = CardDefaults.outlinedCardColors(
|
||||
containerColor = colorScheme.background,
|
||||
contentColor = colorScheme.onBackground
|
||||
),
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = zoom
|
||||
scaleY = zoom
|
||||
}
|
||||
.size(96.dp)
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = {
|
||||
if (selected) return@combinedClickable
|
||||
feedback.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
},
|
||||
onLongClick = {
|
||||
feedback.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
onLongClick()
|
||||
}
|
||||
),
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
|
||||
Crossfade(selected, label = "icon") { selected ->
|
||||
if (!selected) {
|
||||
Icon(
|
||||
imageVector = when (isDark) {
|
||||
true -> Icons.Rounded.DarkMode
|
||||
false -> Icons.Rounded.LightMode
|
||||
},
|
||||
contentDescription = "",
|
||||
tint = when (isDark) {
|
||||
true -> SugarColors.Tee
|
||||
false -> SugarColors.Yellow
|
||||
}
|
||||
.size(96.dp)
|
||||
.interactionBorder(
|
||||
type = InteractionType.PRESS,
|
||||
source = interactionSource,
|
||||
shape = shape,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = {
|
||||
if (selected) return@combinedClickable
|
||||
feedback.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
},
|
||||
onLongClick = {
|
||||
feedback.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
onLongClick()
|
||||
}
|
||||
),
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
|
||||
Crossfade(selected, label = "icon") { selected ->
|
||||
if (!selected) {
|
||||
Icon(
|
||||
imageVector = when (isDark) {
|
||||
true -> Icons.Rounded.DarkMode
|
||||
false -> Icons.Rounded.LightMode
|
||||
},
|
||||
contentDescription = "",
|
||||
tint = when (isDark) {
|
||||
true -> SugarColors.Tee
|
||||
false -> SugarColors.Yellow
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TvCard(
|
||||
colors = TvCardDefaults.colors(
|
||||
containerColor = colorScheme.background,
|
||||
contentColor = colorScheme.onBackground
|
||||
),
|
||||
shape = TvCardDefaults.shape(
|
||||
RoundedCornerShape(spacing.large)
|
||||
),
|
||||
border = TvCardDefaults.border(focusedBorder = Border.None),
|
||||
scale = TvCardDefaults.scale(
|
||||
scale = 0.8f,
|
||||
focusedScale = 0.95f,
|
||||
pressedScale = 0.85f
|
||||
),
|
||||
onClick = {
|
||||
if (selected) return@TvCard
|
||||
onClick()
|
||||
},
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
},
|
||||
modifier = modifier.size(96.dp),
|
||||
content = {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
content()
|
||||
Crossfade(selected, label = "icon") { selected ->
|
||||
if (!selected) {
|
||||
TvIcon(
|
||||
imageVector = when (isDark) {
|
||||
true -> Icons.Rounded.DarkMode
|
||||
false -> Icons.Rounded.LightMode
|
||||
},
|
||||
contentDescription = "",
|
||||
tint = when (isDark) {
|
||||
true -> SugarColors.Tee
|
||||
false -> SugarColors.Yellow
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package com.m3u.material.components.mask
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
@ -8,12 +9,9 @@ import androidx.compose.material3.TooltipState
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.m3u.material.components.IconButton
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import androidx.compose.material3.IconButton
|
||||
|
||||
@Composable
|
||||
fun MaskButton(
|
||||
@ -26,8 +24,6 @@ fun MaskButton(
|
||||
tint: Color = Color.Unspecified,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val tv = tv()
|
||||
|
||||
TooltipBox(
|
||||
state = tooltipState,
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
@ -38,21 +34,18 @@ fun MaskButton(
|
||||
}
|
||||
) {
|
||||
IconButton(
|
||||
icon = icon,
|
||||
enabled = enabled,
|
||||
contentDescription = contentDescription,
|
||||
onClick = {
|
||||
state.wake()
|
||||
onClick()
|
||||
},
|
||||
modifier = modifier.thenIf(tv) {
|
||||
Modifier.onFocusEvent {
|
||||
if (it.isFocused) {
|
||||
state.wake()
|
||||
}
|
||||
}
|
||||
},
|
||||
tint = tint
|
||||
)
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package com.m3u.material.components.mask
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -11,12 +12,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.ktx.tv
|
||||
import androidx.tv.material3.Icon as TvIcon
|
||||
import androidx.tv.material3.LocalContentColor as TvLocalContentColor
|
||||
import androidx.tv.material3.Surface as TvSurface
|
||||
|
||||
@Composable
|
||||
fun MaskCircleButton(
|
||||
@ -29,47 +24,23 @@ fun MaskCircleButton(
|
||||
isSmallDimension: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null
|
||||
) {
|
||||
val tv = tv()
|
||||
val dimension = if (isSmallDimension) 48.dp else 64.dp
|
||||
if (!tv) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
state.wake()
|
||||
onClick()
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
color = Color.Unspecified,
|
||||
contentColor = tint.takeOrElse { LocalContentColor.current }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(dimension)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvSurface(
|
||||
shape = ClickableSurfaceDefaults.shape(CircleShape),
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
state.wake()
|
||||
onClick()
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Unspecified,
|
||||
contentColor = tint.takeOrElse { TvLocalContentColor.current }
|
||||
)
|
||||
) {
|
||||
TvIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(dimension)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
state.wake()
|
||||
onClick()
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
color = Color.Unspecified,
|
||||
contentColor = tint.takeOrElse { LocalContentColor.current }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(dimension)
|
||||
)
|
||||
}
|
||||
}
|
@ -1,685 +0,0 @@
|
||||
package com.m3u.material.components.tv
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
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.composed
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.semantics.dismiss
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import kotlin.math.max
|
||||
import androidx.tv.material3.ColorScheme as TvColorScheme
|
||||
import androidx.tv.material3.LocalContentColor as TvLocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.ProvideTextStyle as TvProvideTextStyle
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun StandardDialog(
|
||||
showDialog: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissButton: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
shape: Shape = StandardDialogDefaults.shape,
|
||||
containerColor: Color = StandardDialogDefaults.containerColor,
|
||||
iconContentColor: Color = StandardDialogDefaults.iconContentColor,
|
||||
titleContentColor: Color = StandardDialogDefaults.titleContentColor,
|
||||
textContentColor: Color = StandardDialogDefaults.textContentColor,
|
||||
tonalElevation: Dp = StandardDialogDefaults.TonalElevation,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
confirmButton: @Composable () -> Unit
|
||||
) {
|
||||
val elevatedContainerColor = TvMaterialTheme.colorScheme.applyTonalElevation(
|
||||
backgroundColor = containerColor,
|
||||
elevation = tonalElevation
|
||||
)
|
||||
|
||||
Dialog(
|
||||
showDialog = showDialog,
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = properties
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(
|
||||
min = StandardDialogDefaults.DialogMinWidth,
|
||||
max = StandardDialogDefaults.DialogMaxWidth
|
||||
)
|
||||
.dialogFocusable()
|
||||
.then(modifier)
|
||||
.graphicsLayer {
|
||||
this.clip = true
|
||||
this.shape = shape
|
||||
}
|
||||
.drawBehind { drawRect(color = elevatedContainerColor) }
|
||||
.padding(StandardDialogDefaults.DialogPadding)
|
||||
) {
|
||||
icon?.let { nnIcon ->
|
||||
CompositionLocalProvider(
|
||||
TvLocalContentColor provides iconContentColor,
|
||||
content = {
|
||||
nnIcon()
|
||||
Spacer(
|
||||
modifier = Modifier.padding(StandardDialogDefaults.IconBottomSpacing)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
title?.let { nnTitle ->
|
||||
CompositionLocalProvider(TvLocalContentColor provides titleContentColor) {
|
||||
TvProvideTextStyle(
|
||||
value = StandardDialogDefaults.titleTextStyle,
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier.heightIn(
|
||||
max = StandardDialogDefaults.TitleMaxHeight
|
||||
)
|
||||
) { nnTitle() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
text?.let { nnText ->
|
||||
CompositionLocalProvider(TvLocalContentColor provides textContentColor) {
|
||||
TvProvideTextStyle(
|
||||
value = StandardDialogDefaults.textStyle,
|
||||
content = {
|
||||
Spacer(modifier = Modifier.padding(StandardDialogDefaults.TextPadding))
|
||||
Box(modifier = Modifier.weight(weight = 1f, fill = false)) {
|
||||
nnText()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(StandardDialogDefaults.ButtonsFlowRowPadding))
|
||||
TvProvideTextStyle(
|
||||
value = StandardDialogDefaults.buttonsTextStyle,
|
||||
content = {
|
||||
DialogFlowRow(
|
||||
mainAxisSpacing = StandardDialogDefaults.ButtonsMainAxisSpacing,
|
||||
crossAxisSpacing = StandardDialogDefaults.ButtonsCrossAxisSpacing
|
||||
) {
|
||||
confirmButton()
|
||||
dismissButton?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun FullScreenDialog(
|
||||
showDialog: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissButton: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
backgroundColor: Color = FullScreenDialogDefaults.backgroundColor,
|
||||
iconContentColor: Color = FullScreenDialogDefaults.iconContentColor,
|
||||
titleContentColor: Color = FullScreenDialogDefaults.titleContentColor,
|
||||
textContentColor: Color = FullScreenDialogDefaults.descriptionContentColor,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
confirmButton: @Composable () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
showDialog = showDialog,
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = properties
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind { drawRect(color = backgroundColor) }
|
||||
.dialogFocusable(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(FullScreenDialogDefaults.DialogMaxWidth)
|
||||
.then(modifier),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
TvLocalContentColor provides iconContentColor,
|
||||
content = {
|
||||
icon?.let { nnIcon ->
|
||||
nnIcon()
|
||||
Spacer(
|
||||
modifier = Modifier.padding(FullScreenDialogDefaults.IconPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
TvLocalContentColor provides titleContentColor,
|
||||
content = {
|
||||
title?.let { nnTitle ->
|
||||
TvProvideTextStyle(
|
||||
value = FullScreenDialogDefaults.titleTextStyle
|
||||
) {
|
||||
nnTitle()
|
||||
Spacer(
|
||||
modifier = Modifier.padding(
|
||||
FullScreenDialogDefaults.TitlePadding
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
TvLocalContentColor provides textContentColor,
|
||||
content = {
|
||||
text?.let { nnText ->
|
||||
TvProvideTextStyle(FullScreenDialogDefaults.descriptionTextStyle) {
|
||||
Box(
|
||||
modifier = Modifier.weight(weight = 1f, fill = false)
|
||||
) { nnText() }
|
||||
Spacer(
|
||||
modifier = Modifier.padding(
|
||||
FullScreenDialogDefaults.DescriptionPadding
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = FullScreenDialogDefaults.ButtonSpacing,
|
||||
alignment = Alignment.CenterHorizontally
|
||||
)
|
||||
) {
|
||||
TvProvideTextStyle(value = FullScreenDialogDefaults.buttonsTextStyle) {
|
||||
confirmButton()
|
||||
dismissButton?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be hoisted to control and observe a [Dialog]'s animation progress.
|
||||
*/
|
||||
@ExperimentalTvMaterial3Api
|
||||
class DialogState {
|
||||
/**
|
||||
* Current animation progress of the [Dialog]. This value will range between 0f and 1f. This
|
||||
* progress is generally linked with the dialog's alpha progress as it is the first element to
|
||||
* be displayed on the view and the last element to the removed from the view.
|
||||
*/
|
||||
var animationProgress by mutableStateOf(0f)
|
||||
private set
|
||||
|
||||
internal fun updateProgress(currentProgress: Float) {
|
||||
animationProgress = currentProgress
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Dialog] displays a full-screen dialog, layered over any other content. It takes a single
|
||||
* composable slot, which is expected to be an opinionated TV dialog content, such as
|
||||
* [StandardDialog], [FullScreenDialog], etc.
|
||||
|
||||
* @param showDialog Controls whether to display the [Dialog]. Set to true initially to trigger
|
||||
* an 'intro' animation and display the [Dialog]. Subsequently, setting to false triggers
|
||||
* an 'outro' animation, then [Dialog] calls [onDismissRequest] and hides itself.
|
||||
* @param onDismissRequest Executes when the user dismisses the dialog.
|
||||
* Must remove the dialog from the composition.
|
||||
* @param modifier Modifier to be applied to the dialog.
|
||||
* @param properties Typically platform specific properties to further configure the dialog.
|
||||
* @param content Slot for dialog content such as [StandardDialog], [FullScreenDialog], etc.
|
||||
*/
|
||||
@ExperimentalComposeUiApi
|
||||
@ExperimentalTvMaterial3Api
|
||||
@Composable
|
||||
fun Dialog(
|
||||
showDialog: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
state: DialogState = remember { DialogState() },
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
// Transitions for background and 'dialog content' alpha.
|
||||
var alphaTransitionState by remember {
|
||||
mutableStateOf(MutableTransitionState(AnimationStage.Intro))
|
||||
}
|
||||
val alphaTransition = updateTransition(alphaTransitionState, label = "alphaTransition")
|
||||
|
||||
// Transitions for dialog content scaling.
|
||||
var scaleTransitionState by remember {
|
||||
mutableStateOf(MutableTransitionState(AnimationStage.Intro))
|
||||
}
|
||||
val scaleTransition = updateTransition(scaleTransitionState, label = "scaleTransition")
|
||||
|
||||
if (showDialog || alphaTransitionState.targetState != AnimationStage.Intro ||
|
||||
scaleTransitionState.targetState != AnimationStage.Intro
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = properties,
|
||||
) {
|
||||
val alpha by animateDialogAlpha(alphaTransition, alphaTransitionState)
|
||||
val scale by animateDialogScale(scaleTransition, scaleTransitionState)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
this.scaleX = scale
|
||||
this.scaleY = scale
|
||||
this.alpha = alpha
|
||||
}
|
||||
.semantics {
|
||||
dismiss {
|
||||
onDismissRequest()
|
||||
true
|
||||
}
|
||||
}
|
||||
.then(modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = content
|
||||
)
|
||||
|
||||
LaunchedEffect(alpha) {
|
||||
state.updateProgress(currentProgress = alpha)
|
||||
}
|
||||
|
||||
// Trigger Outro animation when the caller updates showDialog to false.
|
||||
LaunchedEffect(showDialog) {
|
||||
if (!showDialog) {
|
||||
// a) Fade out dialog contents b) Scale down dialog contents.
|
||||
alphaTransitionState.targetState = AnimationStage.Outro
|
||||
scaleTransitionState.targetState = AnimationStage.Outro
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(alphaTransitionState.currentState) {
|
||||
when (alphaTransitionState.currentState) {
|
||||
AnimationStage.Intro -> {
|
||||
// a) Fade in dialog background b) Scale up dialog contents.
|
||||
alphaTransitionState.targetState = AnimationStage.Display
|
||||
scaleTransitionState.targetState = AnimationStage.Display
|
||||
}
|
||||
|
||||
AnimationStage.Outro -> {
|
||||
// After the outro animation, leave the dialog & reset alpha/scale
|
||||
// transitions.
|
||||
onDismissRequest()
|
||||
alphaTransitionState = MutableTransitionState(AnimationStage.Intro)
|
||||
scaleTransitionState = MutableTransitionState(AnimationStage.Intro)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple clone of FlowRow that arranges its children in a horizontal flow with limited
|
||||
* customization.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DialogFlowRow(
|
||||
mainAxisSpacing: Dp,
|
||||
crossAxisSpacing: Dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
Layout(content) { measurables, constraints ->
|
||||
val sequences = mutableListOf<List<Placeable>>()
|
||||
val crossAxisSizes = mutableListOf<Int>()
|
||||
val crossAxisPositions = mutableListOf<Int>()
|
||||
|
||||
var mainAxisSpace = 0
|
||||
var crossAxisSpace = 0
|
||||
|
||||
val currentSequence = mutableListOf<Placeable>()
|
||||
var currentMainAxisSize = 0
|
||||
var currentCrossAxisSize = 0
|
||||
|
||||
// Return whether the placeable can be added to the current sequence.
|
||||
fun canAddToCurrentSequence(placeable: Placeable) =
|
||||
currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() +
|
||||
placeable.width <= constraints.maxWidth
|
||||
|
||||
// Store current sequence information and start a new sequence.
|
||||
fun startNewSequence() {
|
||||
if (sequences.isNotEmpty()) {
|
||||
crossAxisSpace += crossAxisSpacing.roundToPx()
|
||||
}
|
||||
sequences += currentSequence.toList()
|
||||
crossAxisSizes += currentCrossAxisSize
|
||||
crossAxisPositions += crossAxisSpace
|
||||
|
||||
crossAxisSpace += currentCrossAxisSize
|
||||
mainAxisSpace = max(mainAxisSpace, currentMainAxisSize)
|
||||
|
||||
currentSequence.clear()
|
||||
currentMainAxisSize = 0
|
||||
currentCrossAxisSize = 0
|
||||
}
|
||||
|
||||
val measurablesList = if (isRtl) measurables.reversed() else measurables
|
||||
|
||||
for (measurable in measurablesList) {
|
||||
// Ask the child for its preferred size.
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
// Start a new sequence if there is not enough space.
|
||||
if (!canAddToCurrentSequence(placeable)) startNewSequence()
|
||||
|
||||
// Add the child to the current sequence.
|
||||
if (currentSequence.isNotEmpty()) {
|
||||
currentMainAxisSize += mainAxisSpacing.roundToPx()
|
||||
}
|
||||
currentSequence.add(placeable)
|
||||
currentMainAxisSize += placeable.width
|
||||
currentCrossAxisSize = max(currentCrossAxisSize, placeable.height)
|
||||
}
|
||||
|
||||
if (currentSequence.isNotEmpty()) startNewSequence()
|
||||
|
||||
val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth)
|
||||
|
||||
val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight)
|
||||
|
||||
layout(mainAxisLayoutSize, crossAxisLayoutSize) {
|
||||
sequences.forEachIndexed { i, placeables ->
|
||||
val childrenMainAxisSizes = IntArray(placeables.size) { j ->
|
||||
placeables[j].width +
|
||||
if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0
|
||||
}
|
||||
val arrangement = Arrangement.Bottom
|
||||
// Handle vertical direction
|
||||
val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
|
||||
with(arrangement) {
|
||||
arrange(mainAxisLayoutSize, childrenMainAxisSizes, mainAxisPositions)
|
||||
}
|
||||
placeables.forEachIndexed { j, placeable ->
|
||||
placeable.place(
|
||||
x = mainAxisPositions[j],
|
||||
y = crossAxisPositions[i]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the current dialog a focus group with a [FocusRequester] and restricts the focus from
|
||||
* exiting its bounds while it's visible.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Modifier.dialogFocusable() = composed {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
focusManager.moveFocus(FocusDirection.Enter)
|
||||
}
|
||||
this.then(
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { exit = { FocusRequester.Cancel } }
|
||||
.focusGroup()
|
||||
)
|
||||
}
|
||||
|
||||
object StandardDialogDefaults {
|
||||
internal val DialogMinWidth = 280.dp
|
||||
internal val DialogMaxWidth = 560.dp
|
||||
|
||||
internal val TitleMaxHeight = 56.dp
|
||||
internal val ButtonsMainAxisSpacing = 16.dp
|
||||
internal val ButtonsCrossAxisSpacing = 16.dp
|
||||
|
||||
internal val DialogPadding = PaddingValues(all = 24.dp)
|
||||
internal val IconBottomSpacing = PaddingValues(top = 32.dp)
|
||||
internal val TextPadding = PaddingValues(top = 20.dp)
|
||||
internal val ButtonsFlowRowPadding = PaddingValues(top = 24.dp)
|
||||
private const val TextColorOpacity = 0.8f
|
||||
|
||||
/** The default shape for StandardDialogs */
|
||||
val shape: Shape = RoundedCornerShape(28.0.dp)
|
||||
|
||||
/** The default container color for StandardDialogs */
|
||||
val containerColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.inverseOnSurface
|
||||
|
||||
/** The default icon color for StandardDialogs */
|
||||
val iconContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.secondary
|
||||
|
||||
/** The default title color for StandardDialogs */
|
||||
val titleContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.onSurface
|
||||
|
||||
/** The default [TextStyle] for StandardDialogs' title */
|
||||
val titleTextStyle: TextStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.headlineMedium
|
||||
|
||||
/** The default [TextStyle] for StandardDialogs' buttons */
|
||||
val buttonsTextStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.labelLarge
|
||||
|
||||
/** The default text color for StandardDialogs */
|
||||
val textContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
|
||||
@Composable get() = TvMaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
/** The default text style for StandardDialogs */
|
||||
val textStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.bodyLarge
|
||||
.copy(color = TvLocalContentColor.current.copy(alpha = TextColorOpacity))
|
||||
|
||||
/** The default tonal elevation for StandardDialogs */
|
||||
val TonalElevation: Dp = 6.dp
|
||||
}
|
||||
|
||||
@ExperimentalTvMaterial3Api
|
||||
object FullScreenDialogDefaults {
|
||||
internal val ButtonSpacing = 16.dp
|
||||
internal val DescriptionPadding = PaddingValues(top = 48.dp)
|
||||
internal val TitlePadding = PaddingValues(top = 20.dp)
|
||||
internal val IconPadding = PaddingValues(top = 32.dp)
|
||||
internal const val DialogMaxWidth = .5f
|
||||
private const val DescriptionColorOpacity = 0.8f
|
||||
|
||||
/** The default background color for FullScreenDialogs */
|
||||
val backgroundColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.background
|
||||
|
||||
/** The default icon color for FullScreenDialogs */
|
||||
val iconContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.onSurface
|
||||
|
||||
/** The default title color for FullScreenDialogs */
|
||||
val titleContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.onSurface
|
||||
|
||||
/** The default title text style for FullScreenDialogs */
|
||||
val titleTextStyle: TextStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.headlineMedium
|
||||
.copy(textAlign = TextAlign.Center)
|
||||
|
||||
/** The default buttons text style for FullScreenDialogs */
|
||||
val buttonsTextStyle: TextStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.labelLarge
|
||||
|
||||
/** The default description text color for FullScreenDialogs */
|
||||
val descriptionContentColor: Color
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
/** The default description text style for FullScreenDialogs */
|
||||
val descriptionTextStyle: TextStyle
|
||||
@ReadOnlyComposable
|
||||
@Composable get() = TvMaterialTheme.typography.bodyLarge.copy(
|
||||
textAlign = TextAlign.Center,
|
||||
color = TvLocalContentColor.current.copy(alpha = DescriptionColorOpacity)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun animateDialogAlpha(
|
||||
alphaTransition: Transition<AnimationStage>,
|
||||
alphaTransitionState: MutableTransitionState<AnimationStage>
|
||||
) = alphaTransition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (alphaTransitionState.currentState == AnimationStage.Intro)
|
||||
tween(
|
||||
durationMillis = ENTER_DURATION,
|
||||
easing = MotionTokens.EnterEasing,
|
||||
delayMillis = ENTER_DELAY
|
||||
)
|
||||
else if (alphaTransitionState.targetState == AnimationStage.Outro)
|
||||
tween(
|
||||
durationMillis = EXIT_DURATION,
|
||||
easing = MotionTokens.ExitEasing,
|
||||
delayMillis = EXIT_DELAY
|
||||
)
|
||||
else
|
||||
tween(durationMillis = 0)
|
||||
},
|
||||
label = "alpha"
|
||||
) { stage ->
|
||||
when (stage) {
|
||||
AnimationStage.Intro -> 0.0f
|
||||
AnimationStage.Display -> 1.0f
|
||||
AnimationStage.Outro -> 0.0f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun animateDialogScale(
|
||||
scaleTransition: Transition<AnimationStage>,
|
||||
scaleTransitionState: MutableTransitionState<AnimationStage>
|
||||
) = scaleTransition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (scaleTransitionState.currentState == AnimationStage.Intro)
|
||||
tween(
|
||||
durationMillis = ENTER_DURATION,
|
||||
easing = MotionTokens.EnterEasing,
|
||||
delayMillis = ENTER_DELAY
|
||||
)
|
||||
else
|
||||
tween(
|
||||
durationMillis = EXIT_DURATION,
|
||||
easing = MotionTokens.ExitEasing,
|
||||
delayMillis = EXIT_DELAY
|
||||
)
|
||||
},
|
||||
label = "scale"
|
||||
) { stage ->
|
||||
when (stage) {
|
||||
AnimationStage.Intro -> 0.97f
|
||||
AnimationStage.Display -> 1.0f
|
||||
AnimationStage.Outro -> 0.97f
|
||||
}
|
||||
}
|
||||
|
||||
// Transition stages - scaling and alpha is applied as single Intro/Outro animations.
|
||||
private enum class AnimationStage {
|
||||
Intro, Display, Outro;
|
||||
}
|
||||
|
||||
private const val ENTER_DURATION = 500
|
||||
private const val EXIT_DURATION = 250
|
||||
|
||||
private const val ENTER_DELAY = 250
|
||||
private const val EXIT_DELAY = 150
|
||||
|
||||
object MotionTokens {
|
||||
val EnterEasing = CubicBezierEasing(0.12f, 1f, 0.4f, 1f)
|
||||
val ExitEasing = CubicBezierEasing(0.4f, 1f, 0.12f, 1f)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
private fun TvColorScheme.applyTonalElevation(backgroundColor: Color, elevation: Dp): Color {
|
||||
return if (backgroundColor == surface) {
|
||||
surfaceColorAtElevation(elevation)
|
||||
} else {
|
||||
backgroundColor
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
package com.m3u.material.ktx
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.model.asTvScheme
|
||||
import com.m3u.material.model.asTvTypography
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
|
||||
@Composable
|
||||
fun tv(): Boolean = LocalConfiguration.current.run {
|
||||
val type = uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||
type == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current Platform and apply new colorScheme.
|
||||
* @param fallback apply std material3 MaterialTheme as well.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PlatformTheme(
|
||||
colorScheme: ColorScheme = MaterialTheme.colorScheme,
|
||||
typography: Typography = MaterialTheme.typography,
|
||||
fallback: Boolean = true,
|
||||
block: @Composable () -> Unit
|
||||
) {
|
||||
val tv = tv()
|
||||
val car = false
|
||||
val content = @Composable {
|
||||
when {
|
||||
tv -> {
|
||||
TvMaterialTheme(
|
||||
colorScheme = remember(colorScheme) { colorScheme.asTvScheme() },
|
||||
typography = remember(typography) { typography.asTvTypography() }
|
||||
) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
car -> throw UnsupportedOperationException()
|
||||
else -> block()
|
||||
}
|
||||
}
|
||||
val commonPlatform = !tv && !car
|
||||
if (commonPlatform || fallback) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.includeChildGlowPadding(): Modifier = thenIf(tv()) {
|
||||
Modifier.padding(LocalSpacing.current.medium)
|
||||
}
|
@ -3,17 +3,14 @@ package com.m3u.material.model
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.m3u.material.ktx.PlatformTheme
|
||||
import com.m3u.material.ktx.createScheme
|
||||
import androidx.tv.material3.ColorScheme as TvColorScheme
|
||||
import androidx.tv.material3.Typography as TvTypography
|
||||
|
||||
@Composable
|
||||
@SuppressLint("RestrictedApi")
|
||||
@ -34,61 +31,10 @@ fun Theme(
|
||||
}
|
||||
}
|
||||
|
||||
PlatformTheme(
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
fun ColorScheme.asTvScheme(): TvColorScheme {
|
||||
return TvColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
inversePrimary = inversePrimary,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
background = background,
|
||||
onBackground = onBackground,
|
||||
surface = surface,
|
||||
onSurface = onSurface,
|
||||
surfaceVariant = surfaceVariant,
|
||||
onSurfaceVariant = onSurfaceVariant,
|
||||
surfaceTint = surfaceVariant, // todo
|
||||
inverseSurface = inverseSurface,
|
||||
inverseOnSurface = inverseOnSurface,
|
||||
error = error,
|
||||
onError = onError,
|
||||
errorContainer = errorContainer,
|
||||
onErrorContainer = onErrorContainer,
|
||||
scrim = scrim,
|
||||
border = outline,
|
||||
borderVariant = outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
fun Typography.asTvTypography(): TvTypography {
|
||||
return TvTypography(
|
||||
displayLarge = displayLarge,
|
||||
displayMedium = displayMedium,
|
||||
displaySmall = displaySmall,
|
||||
headlineLarge = headlineLarge,
|
||||
headlineMedium = headlineMedium,
|
||||
headlineSmall = headlineSmall,
|
||||
titleLarge = titleLarge,
|
||||
titleMedium = titleMedium,
|
||||
titleSmall = titleSmall,
|
||||
bodyLarge = bodyLarge,
|
||||
bodyMedium = bodyMedium,
|
||||
bodySmall = bodySmall
|
||||
)
|
||||
}
|
@ -34,3 +34,4 @@ include(":i18n")
|
||||
include(":codec:lite", ":codec:rich")
|
||||
include(":annotation")
|
||||
include(":processor")
|
||||
include(":tv")
|
||||
|
@ -34,8 +34,7 @@ import com.m3u.smartphone.ui.sheet.RemoteControlSheet
|
||||
import com.m3u.smartphone.ui.sheet.RemoteControlSheetValue
|
||||
import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.ktx.tv
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.FontFamilies
|
||||
@ -108,8 +107,6 @@ private fun AppImpl(
|
||||
val spacing = LocalSpacing.current
|
||||
val preferences = hiltPreferences()
|
||||
|
||||
val tv = tv()
|
||||
|
||||
val entry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val rootDestination by remember {
|
||||
@ -147,7 +144,7 @@ private fun AppImpl(
|
||||
) {
|
||||
SnackHost(Modifier.weight(1f))
|
||||
AnimatedVisibility(
|
||||
visible = !tv && preferences.remoteControl,
|
||||
visible = preferences.remoteControl,
|
||||
enter = scaleIn(initialScale = 0.65f) + fadeIn(),
|
||||
exit = scaleOut(targetScale = 0.65f) + fadeOut(),
|
||||
) {
|
||||
|
@ -18,9 +18,7 @@ import com.m3u.feature.playlist.configuration.navigateToPlaylistConfiguration
|
||||
import com.m3u.feature.playlist.configuration.playlistConfigurationScreen
|
||||
import com.m3u.feature.playlist.navigation.navigateToPlaylist
|
||||
import com.m3u.feature.playlist.navigation.playlistScreen
|
||||
import com.m3u.feature.playlist.navigation.playlistTvScreen
|
||||
import com.m3u.feature.channel.PlayerActivity
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.Events
|
||||
import com.m3u.ui.SettingDestination
|
||||
@ -36,8 +34,6 @@ fun AppNavHost(
|
||||
val context = LocalContext.current
|
||||
val preferences = hiltPreferences()
|
||||
|
||||
val tv = tv()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
@ -48,7 +44,7 @@ fun AppNavHost(
|
||||
rootGraph(
|
||||
contentPadding = contentPadding,
|
||||
navigateToPlaylist = { playlist ->
|
||||
navController.navigateToPlaylist(playlist.url, tv)
|
||||
navController.navigateToPlaylist(playlist.url)
|
||||
},
|
||||
navigateToChannel = {
|
||||
if (preferences.zappingMode && PlayerActivity.isInPipMode) return@rootGraph
|
||||
@ -87,7 +83,6 @@ fun AppNavHost(
|
||||
},
|
||||
contentPadding = contentPadding
|
||||
)
|
||||
playlistTvScreen()
|
||||
playlistConfigurationScreen(contentPadding)
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.width
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@ -40,16 +42,12 @@ import androidx.compose.ui.unit.offset
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import androidx.compose.ui.util.fastMaxOfOrNull
|
||||
import com.m3u.smartphone.ui.internal.SmartphoneScaffoldImpl
|
||||
import com.m3u.smartphone.ui.internal.TabletScaffoldImpl
|
||||
import com.m3u.smartphone.ui.internal.TvScaffoldImpl
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.components.IconButton
|
||||
import com.m3u.material.effects.currentBackStackEntry
|
||||
import com.m3u.material.ktx.tv
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.smartphone.ui.internal.SmartphoneScaffoldImpl
|
||||
import com.m3u.smartphone.ui.internal.TabletScaffoldImpl
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.FontFamilies
|
||||
import com.m3u.ui.helper.Fob
|
||||
@ -59,8 +57,6 @@ import com.m3u.ui.helper.useRailNav
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import androidx.tv.material3.Icon as TvIcon
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
@OptIn(InternalComposeApi::class)
|
||||
@ -73,23 +69,12 @@ internal fun Scaffold(
|
||||
content: @Composable BoxScope.(PaddingValues) -> Unit
|
||||
) {
|
||||
val useRailNav = LocalHelper.current.useRailNav
|
||||
val tv = tv()
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
CompositionLocalProvider(LocalHazeState provides hazeState) {
|
||||
Background {
|
||||
when {
|
||||
tv -> {
|
||||
TvScaffoldImpl(
|
||||
rootDestination = rootDestination,
|
||||
navigateToRoot = navigateToRootDestination,
|
||||
onBackPressed = onBackPressed,
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
!useRailNav -> {
|
||||
SmartphoneScaffoldImpl(
|
||||
rootDestination = rootDestination,
|
||||
@ -132,7 +117,6 @@ internal fun MainContent(
|
||||
onBackPressed: (() -> Unit)?,
|
||||
content: @Composable BoxScope.(PaddingValues) -> Unit
|
||||
) {
|
||||
val tv = tv()
|
||||
val spacing = LocalSpacing.current
|
||||
val hazeState = LocalHazeState.current
|
||||
|
||||
@ -144,72 +128,76 @@ internal fun MainContent(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!tv) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(Color.Transparent),
|
||||
windowInsets = windowInsets,
|
||||
title = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.defaultMinSize(minHeight = 56.dp)
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(Color.Transparent),
|
||||
windowInsets = windowInsets,
|
||||
title = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.defaultMinSize(minHeight = 56.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = spacing.medium)
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = spacing.medium)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontFamily = FontFamilies.LexendExa
|
||||
)
|
||||
AnimatedVisibility(subtitle.text.isNotEmpty()) {
|
||||
Text(
|
||||
text = title,
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontFamily = FontFamilies.LexendExa
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
AnimatedVisibility(subtitle.text.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
actions.forEach { action ->
|
||||
IconButton(
|
||||
icon = action.icon,
|
||||
contentDescription = action.contentDescription,
|
||||
onClick = action.onClick,
|
||||
enabled = action.enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(spacing.medium))
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
AnimatedContent(
|
||||
targetState = onBackPressed,
|
||||
label = "app-scaffold-icon"
|
||||
) { onBackPressed ->
|
||||
if (onBackPressed != null) {
|
||||
|
||||
Row {
|
||||
actions.forEach { action ->
|
||||
IconButton(
|
||||
icon = backStackEntry?.navigationIcon
|
||||
onClick = action.onClick,
|
||||
enabled = action.enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = action.icon,
|
||||
contentDescription = action.contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(spacing.medium))
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
AnimatedContent(
|
||||
targetState = onBackPressed,
|
||||
label = "app-scaffold-icon"
|
||||
) { onBackPressed ->
|
||||
if (onBackPressed != null) {
|
||||
IconButton(
|
||||
onClick = onBackPressed,
|
||||
modifier = Modifier.wrapContentSize()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = backStackEntry?.navigationIcon
|
||||
?: Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = null,
|
||||
onClick = onBackPressed,
|
||||
modifier = Modifier.wrapContentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeChild(hazeState, style = HazeStyle(blurRadius = 6.dp))
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeChild(hazeState, style = HazeStyle(blurRadius = 6.dp))
|
||||
.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
contentWindowInsets = windowInsets,
|
||||
containerColor = Color.Transparent
|
||||
@ -238,47 +226,26 @@ internal fun NavigationItemLayout(
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val tv = tv()
|
||||
val usefob = fob?.rootDestination == currentRootDestination
|
||||
val selected = usefob || currentRootDestination == rootDestination
|
||||
val icon = @Composable {
|
||||
if (!tv) {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
fob != null && usefob -> fob.icon
|
||||
selected -> currentRootDestination.selectedIcon
|
||||
else -> currentRootDestination.unselectedIcon
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
TvIcon(
|
||||
imageVector = when {
|
||||
fob != null && usefob -> fob.icon
|
||||
selected -> currentRootDestination.selectedIcon
|
||||
else -> currentRootDestination.unselectedIcon
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = when {
|
||||
fob != null && usefob -> fob.icon
|
||||
selected -> currentRootDestination.selectedIcon
|
||||
else -> currentRootDestination.unselectedIcon
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
val label: @Composable () -> Unit = remember(usefob, fob) {
|
||||
@Composable {
|
||||
if (!tv) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (usefob) fob.iconTextId
|
||||
else currentRootDestination.iconTextId
|
||||
).uppercase()
|
||||
)
|
||||
} else {
|
||||
TvText(
|
||||
text = stringResource(
|
||||
if (usefob) fob.iconTextId
|
||||
else currentRootDestination.iconTextId
|
||||
).uppercase()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (usefob) fob.iconTextId
|
||||
else currentRootDestination.iconTextId
|
||||
).uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
val actualOnClick: () -> Unit = if (usefob) {
|
||||
|
@ -32,7 +32,6 @@ import androidx.graphics.shapes.RoundedPolygon
|
||||
import androidx.graphics.shapes.star
|
||||
import androidx.graphics.shapes.toPath
|
||||
import com.m3u.core.architecture.preferences.hiltPreferences
|
||||
import com.m3u.material.ktx.tv
|
||||
|
||||
data class StarSpec(
|
||||
val numVertices: Int,
|
||||
@ -88,11 +87,10 @@ fun StarBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
colors: StarColors = StarColors.defaults(),
|
||||
) {
|
||||
val tv = tv()
|
||||
val preferences = hiltPreferences()
|
||||
val specs = remember(colors) { createStarSpecs(colors) }
|
||||
AnimatedVisibility(
|
||||
visible = !tv && preferences.colorfulBackground,
|
||||
visible = preferences.colorfulBackground,
|
||||
enter = fadeIn() + scaleIn(initialScale = 2.3f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 2.3f),
|
||||
modifier = modifier
|
||||
|
@ -1,139 +0,0 @@
|
||||
package com.m3u.smartphone.ui.internal
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m3u.smartphone.ui.Items
|
||||
import com.m3u.smartphone.ui.MainContent
|
||||
import com.m3u.smartphone.ui.NavigationItemLayout
|
||||
import com.m3u.smartphone.ui.ScaffoldLayout
|
||||
import com.m3u.smartphone.ui.ScaffoldRole
|
||||
import com.m3u.material.components.Background
|
||||
import com.m3u.material.ktx.plus
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.helper.Metadata
|
||||
import androidx.tv.material3.Border as TvBorder
|
||||
import androidx.tv.material3.Card as TvCard
|
||||
import androidx.tv.material3.CardDefaults as TvCardDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
|
||||
@Composable
|
||||
@InternalComposeApi
|
||||
fun TvScaffoldImpl(
|
||||
rootDestination: Destination.Root?,
|
||||
navigateToRoot: (Destination.Root) -> Unit,
|
||||
onBackPressed: (() -> Unit)?,
|
||||
content: @Composable BoxScope.(PaddingValues) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val fob = Metadata.fob
|
||||
|
||||
val navigation = @Composable {
|
||||
TvNavigation {
|
||||
Items { currentRootDestination ->
|
||||
NavigationItemLayout(
|
||||
rootDestination = rootDestination,
|
||||
fob = fob,
|
||||
currentRootDestination = currentRootDestination,
|
||||
navigateToRoot = navigateToRoot
|
||||
) { selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
_: @Composable () -> Unit ->
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val focused by source.collectIsFocusedAsState()
|
||||
val currentContainerColor by with(TvMaterialTheme.colorScheme) {
|
||||
animateColorAsState(
|
||||
targetValue = when {
|
||||
selected -> inverseSurface
|
||||
focused -> primaryContainer.copy(0.67f)
|
||||
else -> background
|
||||
},
|
||||
label = "scaffold-navigation-container"
|
||||
)
|
||||
}
|
||||
val currentContentColor by with(TvMaterialTheme.colorScheme) {
|
||||
animateColorAsState(
|
||||
targetValue = when {
|
||||
selected -> inverseOnSurface
|
||||
focused -> onPrimaryContainer
|
||||
else -> onBackground
|
||||
},
|
||||
label = "scaffold-navigation-content"
|
||||
)
|
||||
}
|
||||
TvCard(
|
||||
onClick = onClick,
|
||||
colors = TvCardDefaults.colors(
|
||||
containerColor = currentContainerColor,
|
||||
contentColor = currentContentColor
|
||||
),
|
||||
interactionSource = source,
|
||||
shape = TvCardDefaults.shape(CircleShape),
|
||||
border = TvCardDefaults.border(focusedBorder = TvBorder.None),
|
||||
scale = TvCardDefaults.scale(
|
||||
scale = if (selected) 1.1f else 1f,
|
||||
focusedScale = if (selected) 1.2f else 1.1f
|
||||
),
|
||||
content = {
|
||||
Box(modifier = Modifier.padding(spacing.medium)) { icon() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val mainContent = @Composable { contentPadding: PaddingValues ->
|
||||
MainContent(
|
||||
windowInsets = WindowInsets.systemBars,
|
||||
onBackPressed = onBackPressed,
|
||||
content = { content(it + contentPadding) }
|
||||
)
|
||||
}
|
||||
|
||||
Background(modifier) {
|
||||
ScaffoldLayout(
|
||||
role = ScaffoldRole.Tv,
|
||||
navigation = navigation,
|
||||
mainContent = mainContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvNavigation(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
spacing.medium,
|
||||
Alignment.CenterVertically
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.padding(spacing.medium)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
@ -23,7 +23,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowUp
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
@ -15,7 +15,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.ripple
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
1
tv/.gitignore
vendored
Normal file
1
tv/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
104
tv/build.gradle.kts
Normal file
104
tv/build.gradle.kts
Normal file
@ -0,0 +1,104 @@
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.com.android.application)
|
||||
alias(libs.plugins.org.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.com.google.dagger.hilt.android)
|
||||
alias(libs.plugins.com.google.devtools.ksp)
|
||||
alias(libs.plugins.androidx.baselineprofile)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
android {
|
||||
namespace = "com.m3u.tv"
|
||||
compileSdk = 35
|
||||
defaultConfig {
|
||||
applicationId = "com.m3u.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 33
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
all {
|
||||
isCrunchPngs = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
aaptOptions.cruncherEnabled = false
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources.excludes += "META-INF/**"
|
||||
}
|
||||
}
|
||||
|
||||
hilt {
|
||||
enableAggregatingTask = true
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
dexLayoutOptimization = true
|
||||
saveInSrc = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":ui"))
|
||||
implementation(project(":feature:foryou"))
|
||||
implementation(project(":feature:favorite"))
|
||||
implementation(project(":feature:setting"))
|
||||
implementation(project(":feature:playlist"))
|
||||
implementation(project(":feature:channel"))
|
||||
implementation(project(":feature:playlist-configuration"))
|
||||
implementation(project(":feature:crash"))
|
||||
// implementation(libs.androidx.profileinstaller)
|
||||
// "baselineProfile"(project(":baselineprofile"))
|
||||
|
||||
// tv
|
||||
api(libs.androidx.tv.material)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.startup.runtime)
|
||||
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.google.dagger.hilt)
|
||||
ksp(libs.google.dagger.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
debugImplementation(libs.squareup.leakcanary)
|
||||
}
|
21
tv/proguard-rules.pro
vendored
Normal file
21
tv/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
31
tv/src/main/AndroidManifest.xml
Normal file
31
tv/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.M3U">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
45
tv/src/main/java/com/m3u/tv/MainActivity.kt
Normal file
45
tv/src/main/java/com/m3u/tv/MainActivity.kt
Normal file
@ -0,0 +1,45 @@
|
||||
package com.m3u.tv
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.tv.material3.Surface
|
||||
import com.m3u.tv.ui.theme.M3UTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
M3UTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RectangleShape
|
||||
) {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
M3UTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
11
tv/src/main/java/com/m3u/tv/ui/theme/Color.kt
Normal file
11
tv/src/main/java/com/m3u/tv/ui/theme/Color.kt
Normal file
@ -0,0 +1,11 @@
|
||||
package com.m3u.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
34
tv/src/main/java/com/m3u/tv/ui/theme/Theme.kt
Normal file
34
tv/src/main/java/com/m3u/tv/ui/theme/Theme.kt
Normal file
@ -0,0 +1,34 @@
|
||||
package com.m3u.tv.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import androidx.tv.material3.lightColorScheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun M3UTheme(
|
||||
isInDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (isInDarkTheme) {
|
||||
darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
36
tv/src/main/java/com/m3u/tv/ui/theme/Type.kt
Normal file
36
tv/src/main/java/com/m3u/tv/ui/theme/Type.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package com.m3u.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Typography
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
BIN
tv/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
BIN
tv/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
tv/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
BIN
tv/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 982 B |
BIN
tv/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
BIN
tv/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
BIN
tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
BIN
tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
BIN
tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
BIN
tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
3
tv/src/main/res/values/strings.xml
Normal file
3
tv/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">tv</string>
|
||||
</resources>
|
4
tv/src/main/res/values/themes.xml
Normal file
4
tv/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
|
||||
<style name="Theme.M3U" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -35,7 +36,7 @@ import com.m3u.data.database.model.Channel
|
||||
import com.m3u.data.parser.xtream.XtreamChannelInfo
|
||||
import com.m3u.material.components.BottomSheet
|
||||
import com.m3u.material.components.CircularProgressIndicator
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.material.shape.AbsoluteSmoothCornerShape
|
||||
|
||||
@ -85,10 +86,13 @@ fun EpisodesBottomSheet(
|
||||
|
||||
else -> {
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = null,
|
||||
onClick = onRefresh
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -29,7 +30,7 @@ import com.m3u.data.database.model.Playlist
|
||||
import com.m3u.data.database.model.Channel
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.BottomSheet
|
||||
import com.m3u.material.components.IconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.MediaSheetValue.FavouriteScreen
|
||||
import com.m3u.ui.MediaSheetValue.ForyouScreen
|
||||
@ -210,10 +211,13 @@ private fun RowScope.ForyouScreenMediaSheetHeaderImpl(
|
||||
}
|
||||
|
||||
IconButton(
|
||||
icon = Icons.Rounded.Edit,
|
||||
contentDescription = null,
|
||||
onClick = { onPlaylistConfiguration(playlist) }
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Edit,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.service.collectMessageAsState
|
||||
import com.m3u.material.components.Icon
|
||||
import androidx.compose.material3.Icon
|
||||
import com.m3u.material.model.LocalDuration
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import kotlinx.coroutines.delay
|
||||
|
@ -1,23 +1,14 @@
|
||||
package com.m3u.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
@ -25,22 +16,14 @@ import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.DenseListItem
|
||||
import com.m3u.data.repository.channel.ChannelRepository
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.BottomSheet
|
||||
import com.m3u.material.components.Icon
|
||||
import com.m3u.material.components.tv.dialogFocusable
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import androidx.tv.material3.ListItemDefaults as TvListItemDefaults
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Immutable
|
||||
enum class Sort(@StringRes val resId: Int) {
|
||||
@ -134,61 +117,3 @@ private fun SortBottomSheetItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvSortFullScreenDialog(
|
||||
visible: Boolean,
|
||||
sort: Sort,
|
||||
sorts: List<Sort>,
|
||||
onChanged: (Sort) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier)
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.4f)
|
||||
.align(Alignment.CenterEnd)
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.background(TvMaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(12.dp)
|
||||
.selectableGroup()
|
||||
.dialogFocusable(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(sorts) { currentSort ->
|
||||
DenseListItem(
|
||||
selected = currentSort == sort,
|
||||
onClick = { onChanged(currentSort) },
|
||||
leadingContent = {},
|
||||
headlineContent = {
|
||||
TvText(currentSort.name)
|
||||
},
|
||||
trailingContent = {
|
||||
if (currentSort == sort) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
scale = TvListItemDefaults.scale(0.95f, 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user