1. volume button is invisible.
2. play control is too big.
3. initial volume is incorrect.
4. channel gallery in player panel has been redesigned.
5. remove test code.
6. handle exception when read-write channel preference.
This commit is contained in:
oxy-macmini
2025-03-23 16:51:34 +08:00
parent 89bff4bb48
commit 5f0f82abfa
9 changed files with 143 additions and 93 deletions

View File

@ -73,21 +73,21 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.m3u.business.channel.PlayerState
import com.m3u.core.architecture.preferences.hiltPreferences
import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.util.basic.isNotEmpty
import com.m3u.data.database.model.AdjacentChannels
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.smartphone.ui.business.channel.components.MaskTextButton
import com.m3u.smartphone.ui.business.channel.components.PlayerMask
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.material.components.FontFamilies
import com.m3u.smartphone.ui.material.components.Image
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.material.components.mask.MaskButton
import com.m3u.smartphone.ui.material.components.mask.MaskCircleButton
import com.m3u.smartphone.ui.material.components.mask.MaskPanel
import com.m3u.smartphone.ui.material.components.mask.MaskState
import com.m3u.smartphone.ui.material.effects.currentBackStackEntry
import com.m3u.core.foundation.ui.thenIf
import com.m3u.smartphone.ui.material.model.LocalSpacing
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@ -319,7 +319,7 @@ internal fun ChannelMask(
preferences.alwaysShowReplay,
playerState.playerError
)
Box(Modifier.size(48.dp)) {
Box(Modifier.size(36.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !isPanelExpanded && adjacentChannels?.prevId != null,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 6 }),
@ -334,7 +334,7 @@ internal fun ChannelMask(
}
}
Box(Modifier.size(64.dp)) {
Box(Modifier.size(52.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !isPanelExpanded && centerRole != MaskCenterRole.Loading,
enter = fadeIn(),
@ -350,12 +350,12 @@ internal fun ChannelMask(
)
}
}
Box(Modifier.size(48.dp)) {
Box(Modifier.size(36.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !isPanelExpanded && adjacentChannels?.nextId != null,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { it / 6 }),
exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it / 6 }),
modifier = Modifier.size(48.dp)
modifier = Modifier.fillMaxSize()
) {
MaskNavigateButton(
state = maskState,
@ -381,7 +381,10 @@ internal fun ChannelMask(
.semantics(mergeDescendants = true) { }
.weight(1f)
) {
if (!isPanelExpanded || !isPanelGestureSupported) {
val alpha by animateFloatAsState(
if (!isPanelExpanded || !isPanelGestureSupported) 1f else 0f
)
Column(Modifier.alpha(alpha)) {
Text(
text = playlistTitle.trim().uppercase(),
style = MaterialTheme.typography.labelMedium,
@ -434,26 +437,26 @@ internal fun ChannelMask(
)
}
}
val autoRotating by ChannelMaskUtils.IsAutoRotatingEnabled
LaunchedEffect(autoRotating) {
if (autoRotating) {
helper.screenOrientation =
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
if (preferences.screenRotating && !autoRotating) {
MaskButton(
state = maskState,
icon = Icons.Rounded.ScreenRotationAlt,
onClick = {
helper.screenOrientation = when (helper.screenOrientation) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
},
contentDescription = stringResource(string.feat_channel_tooltip_screen_rotating)
)
val autoRotating by ChannelMaskUtils.IsAutoRotatingEnabled
LaunchedEffect(autoRotating) {
if (autoRotating) {
helper.screenOrientation =
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
if (preferences.screenRotating && !autoRotating) {
MaskButton(
state = maskState,
icon = Icons.Rounded.ScreenRotationAlt,
onClick = {
helper.screenOrientation = when (helper.screenOrientation) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
},
contentDescription = stringResource(string.feat_channel_tooltip_screen_rotating)
)
}
},
slider = {
when {

View File

@ -226,8 +226,7 @@ fun ChannelRoute(
ChannelPlayer(
isSeriesPlaylist = isSeriesPlaylist,
openDlnaDevices = {
// viewModel.recordVideo()
createRecordFileLauncher.launch("record_${Clock.System.now().toEpochMilliseconds()}.mp4")
// createRecordFileLauncher.launch("record_${Clock.System.now().toEpochMilliseconds()}.mp4")
viewModel.openDlnaDevices()
pullPanelLayoutState.collapse()
},

View File

@ -16,6 +16,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.takeOrElse
import com.m3u.smartphone.ui.material.components.mask.MaskState
import com.m3u.smartphone.ui.material.components.FontFamilies
@ -58,12 +61,14 @@ fun MaskTextButton(
state.wake()
onClick()
},
enabled = enabled
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(
contentColor = tint.takeOrElse { LocalContentColor.current }
),
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = tint
contentDescription = contentDescription
)
}
}

View File

@ -31,6 +31,7 @@ internal fun PlayerMask(
body: @Composable RowScope.() -> Unit,
footer: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
control: @Composable RowScope.() -> Unit = {},
slider: (@Composable () -> Unit)? = null
) {
val configuration = LocalConfiguration.current
@ -78,6 +79,12 @@ internal fun PlayerMask(
bottom = spacing.small
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Bottom,
content = control
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.medium),

View File

@ -33,7 +33,11 @@ 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -58,26 +62,26 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import coil.compose.SubcomposeAsyncImage
import com.m3u.core.foundation.components.CircularProgressIndicator
import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.util.collections.indexOf
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Episode
import com.m3u.data.database.model.Programme
import com.m3u.data.database.model.ProgrammeRange
import com.m3u.data.service.MediaCommand
import com.m3u.smartphone.TimeUtils.formatEOrSh
import com.m3u.smartphone.TimeUtils.toEOrSh
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.material.components.Background
import com.m3u.core.foundation.components.CircularProgressIndicator
import androidx.compose.material3.IconButton
import com.m3u.smartphone.ui.material.components.FontFamilies
import com.m3u.smartphone.ui.material.effects.BackStackEntry
import com.m3u.smartphone.ui.material.effects.BackStackHandler
import com.m3u.smartphone.ui.material.ktx.Edge
import com.m3u.smartphone.ui.material.ktx.blurEdges
import com.m3u.core.foundation.ui.thenIf
import com.m3u.smartphone.ui.material.ktx.composableOf
import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.smartphone.ui.material.shape.AbsoluteSmoothCornerShape
import com.m3u.smartphone.ui.material.components.FontFamilies
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.TimeUtils.formatEOrSh
import com.m3u.smartphone.TimeUtils.toEOrSh
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@ -364,8 +368,12 @@ private fun ChannelGallery(
val isPlaying = channel.id == channelId
ChannelGalleryItem(
channel = channel,
isPlaying = isPlaying
isPlaying = isPlaying,
isRoundedShape = !vertical
)
if (vertical) {
HorizontalDivider()
}
}
}
}
@ -388,9 +396,10 @@ private fun ChannelGallery(
} else {
LazyColumn(
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(spacing.medium),
contentPadding = PaddingValues(spacing.medium),
modifier = modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.blurEdges(MaterialTheme.colorScheme.surface, listOf(Edge.Top, Edge.Bottom))
.then(modifier),
content = content
)
}
@ -413,32 +422,18 @@ private sealed class ChannelGalleryValue {
private fun ChannelGalleryItem(
channel: Channel,
isPlaying: Boolean,
isRoundedShape: Boolean,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
val helper = LocalHelper.current
val coroutineScope = rememberCoroutineScope()
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
) {
val containerColor = if (!isPlaying) MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.medium)
else MaterialTheme.colorScheme.onSurface
val contentColor = if (!isPlaying) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.surfaceColorAtElevation(spacing.small)
val text = composableOf {
Text(
text = channel.title,
style = MaterialTheme.typography.bodyMedium,
@ -446,6 +441,39 @@ private fun ChannelGalleryItem(
modifier = Modifier.padding(spacing.medium)
)
}
val onClick = lambda@{
if (isPlaying) return@lambda
coroutineScope.launch {
helper.play(
MediaCommand.Common(channel.id)
)
}
}
if (isRoundedShape) {
Card(
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor
),
shape = AbsoluteRoundedCornerShape(spacing.medium),
elevation = CardDefaults.cardElevation(spacing.none),
onClick = onClick,
modifier = modifier
) {
text()
}
} else {
ListItem(
headlineContent = {
text()
},
colors = ListItemDefaults.colors(
containerColor = containerColor,
headlineColor = contentColor
),
modifier = Modifier.clickable(onClick = onClick).then(modifier)
)
}
}
@Composable

View File

@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BrokenImage
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
@ -41,14 +42,14 @@ import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import coil.size.Size
import com.m3u.core.architecture.preferences.hiltPreferences
import com.m3u.data.database.model.Programme
import com.m3u.data.database.model.Channel
import com.m3u.i18n.R.string
import com.m3u.core.foundation.components.CircularProgressIndicator
import androidx.compose.material3.Icon
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Programme
import com.m3u.i18n.R.string
import com.m3u.smartphone.TimeUtils.formatEOrSh
import com.m3u.smartphone.ui.material.ktx.composableOf
import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.smartphone.ui.material.shape.AbsoluteSmoothCornerShape
import com.m3u.smartphone.TimeUtils.formatEOrSh
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
@ -175,16 +176,14 @@ internal fun ChannelItem(
fontWeight = FontWeight.Bold,
)
},
leadingContent = if (!noPictureMode) {
{
AsyncImage(
model = channel.cover,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(56.dp)
)
}
} else null,
leadingContent = composableOf(!noPictureMode) {
AsyncImage(
model = channel.cover,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(56.dp)
)
},
supportingContent = {
when {
recently -> {
@ -220,9 +219,7 @@ internal fun ChannelItem(
}
}
},
trailingContent = {
star()
},
trailingContent = star,
colors = ListItemDefaults.colors(Color.Transparent),
modifier = Modifier
.combinedClickable(

View File

@ -1,5 +1,6 @@
package com.m3u.smartphone.ui.material.components
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.detectVerticalDragGestures
@ -57,6 +58,7 @@ fun PullPanelLayout(
BackHandler(state.value == PullPanelLayoutValue.EXPANDED) {
state.collapse()
}
@SuppressLint("UnusedBoxWithConstraintsScope")
BoxWithConstraints {
var offset: Float by remember(state, constraints.maxHeight) {
mutableFloatStateOf(

View File

@ -82,7 +82,11 @@ class ChannelViewModel @Inject constructor(
var devices by mutableStateOf(emptyList<Device>())
private val _volume: MutableStateFlow<Float> by lazy {
MutableStateFlow(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / 100f)
MutableStateFlow(
with(audioManager) {
getStreamVolume(AudioManager.STREAM_MUSIC) * 1f / getStreamMaxVolume(AudioManager.STREAM_MUSIC)
}
)
}
val volume = _volume.asStateFlow()

View File

@ -14,19 +14,24 @@ internal class ChannelPreferenceProvider(
private val cache = DiskLruCache.open(directory, appVersion, 2, 4 * 1024 * 1024) // 4mb
suspend operator fun get(channelUrl: String): ChannelPreference? = withContext(ioDispatcher) {
val key = encodeKey(channelUrl)
val snapshot = cache.get(key) ?: return@withContext null
ChannelPreference(
cwPosition = snapshot.getString(0).toLong(),
mineType = snapshot.getString(1)
)
runCatching {
val key = encodeKey(channelUrl)
val snapshot = cache.get(key) ?: return@withContext null
ChannelPreference(
cwPosition = snapshot.getString(0).toLong(),
mineType = snapshot.getString(1)
)
}
.getOrNull()
}
suspend operator fun set(channelUrl: String, value: ChannelPreference) = withContext(ioDispatcher) {
val key = encodeKey(channelUrl)
val editor = cache.edit(key) ?: return@withContext
editor.set(0, value.cwPosition.toString())
editor.set(1, value.mineType)
editor.commit()
runCatching {
val key = encodeKey(channelUrl)
val editor = cache.edit(key) ?: return@withContext
editor.set(0, value.cwPosition.toString())
editor.set(1, value.mineType)
editor.commit()
}
}
// [a-z0-9_-]{1,64}