refactor: separate TV code.

This commit is contained in:
oxy-macmini
2025-03-09 22:06:19 +08:00
parent 49196c7df4
commit a0c62ac5df
81 changed files with 1217 additions and 3995 deletions

View File

@ -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)
)
}
}

View File

@ -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 =

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
)
}
}
}
}

View File

@ -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,

View File

@ -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)
)
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)
}
}
}
}

View File

@ -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
)
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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),
)
}
}

View File

@ -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()
}

View File

@ -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

View File

@ -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

View File

@ -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()
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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()
}
}
}

View File

@ -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 = ""
)
}
}
}
}

View File

@ -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
}
}
}
}
}
}

View File

@ -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()
)
)
}
}
}
}

View File

@ -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() }
)
}

View File

@ -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()
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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 = {

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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 }
)
}
}
}

View File

@ -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 }

View File

@ -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 {

View File

@ -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" }

View File

@ -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>

View File

@ -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)

View File

@ -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()
}

View File

@ -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
)
}
)
}
}
}
}

View File

@ -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 }
)
}
}

View File

@ -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
)

View File

@ -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
)
)
}
}
}
)
}
}

View File

@ -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
}
)
}
}
}
}
)
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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)
)
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
)
}

View File

@ -34,3 +34,4 @@ include(":i18n")
include(":codec:lite", ":codec:rich")
include(":annotation")
include(":processor")
include(":tv")

View File

@ -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(),
) {

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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()
}
}

View File

@ -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

View File

@ -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
View File

@ -0,0 +1 @@
/build

104
tv/build.gradle.kts Normal file
View 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
View 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

View 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>

View 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")
}
}

View 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)

View 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
)
}

View 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
)
*/
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">tv</string>
</resources>

View File

@ -0,0 +1,4 @@
<resources>
<style name="Theme.M3U" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

View File

@ -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,
)
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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

View File

@ -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()
}
}
}
}