From e48ee326d48d65a840b119a330c44dc0bdbbd76c Mon Sep 17 00:00:00 2001 From: oxy-macmini Date: Tue, 6 May 2025 23:43:49 +0800 Subject: [PATCH] feat: pager gallery. --- .../ui/business/channel/ChannelMask.kt | 62 ++++++++++---- .../ui/business/channel/ChannelMaskUtils.kt | 24 ++++-- .../ui/business/channel/ChannelScreen.kt | 71 +++++++++------- .../business/channel/components/PlayerMask.kt | 39 +++------ .../channel/components/PlayerPanel.kt | 8 +- .../ui/business/foryou/ForyouScreen.kt | 83 +++++++------------ .../foryou/components/PlaylistGallery.kt | 13 +-- .../ui/business/playlist/PlaylistScreen.kt | 73 +++++++++++----- .../playlist/components/ChannelGallery.kt | 14 ++-- .../playlist/components/PlaylistTabRow.kt | 4 +- .../com/m3u/smartphone/ui/common/Scaffold.kt | 4 +- .../m3u/smartphone/ui/common/helper/Helper.kt | 12 ++- .../ui/material/components/mask/Mask.kt | 17 ++-- .../com/m3u/tv/screens/foryou/ForyouScreen.kt | 48 ++++------- .../m3u/tv/screens/playlist/ChannelGallery.kt | 10 ++- .../m3u/tv/screens/playlist/PlaylistScreen.kt | 6 +- .../m3u/business/foryou/ForyouViewModel.kt | 18 ++-- .../business/playlist/PlaylistViewModel.kt | 60 ++++++-------- .../com/m3u/data/database/model/Playlist.kt | 5 +- .../repository/playlist/PlaylistRepository.kt | 2 +- .../playlist/PlaylistRepositoryImpl.kt | 6 +- 21 files changed, 301 insertions(+), 278 deletions(-) diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt index 8de2adf4..010c163f 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt @@ -2,6 +2,7 @@ package com.m3u.smartphone.ui.business.channel import android.content.pm.ActivityInfo import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -74,8 +75,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastRoundToInt import androidx.media3.common.Player import com.m3u.business.channel.PlayerState import com.m3u.core.architecture.preferences.PreferencesKeys @@ -87,7 +92,7 @@ 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.business.channel.components.MaskDimension +import com.m3u.smartphone.ui.business.channel.components.Paddings 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 @@ -135,7 +140,7 @@ fun ChannelMask( onVolume: (Float) -> Unit, onNextChannelClick: () -> Unit, onPreviousChannelClick: () -> Unit, - onDimensionChanged: (MaskDimension) -> Unit, + onPaddingsChanged: (Paddings) -> Unit, modifier: Modifier = Modifier, ) { val helper = LocalHelper.current @@ -150,6 +155,9 @@ fun ChannelMask( // they must be wrapped with rememberUpdatedState when using them. val currentVolume by rememberUpdatedState(volume) val currentBrightness by rememberUpdatedState(brightness) + val currentUseVertical by rememberUpdatedState(useVertical) + val currentIsPanelExpanded by rememberUpdatedState(isPanelExpanded) + val currentCwPosition by rememberUpdatedState(cwPosition) val muted = currentVolume == 0f @@ -247,13 +255,19 @@ fun ChannelMask( modifier = Modifier.align(Alignment.Center) ) val color by animateColorAsState( - Color.Black.copy(alpha = if (isPanelExpanded) 0f else 0.54f) + Color.Black.copy(alpha = if (currentIsPanelExpanded) 0f else 0.54f) ) val playStateDisplayText = ChannelMaskUtils.playStateDisplayText(playerState.playState) val exceptionDisplayText = ChannelMaskUtils.playbackExceptionDisplayText(playerState.playerError) - val cwPositionObj = cwPosition.takeIf { it != -1L }?.let { CwPosition(it) } + val cwPositionObj = run { + currentCwPosition.takeIf { + currentUseVertical && !currentIsPanelExpanded && playerState.playState == Player.STATE_READY + && it != -1L + }?.let { CwPosition(it) } + } + PlayerMask( state = maskState, color = color, @@ -305,10 +319,10 @@ fun ChannelMask( ) } - if (!useVertical) { + if (!currentUseVertical) { MaskButton( state = maskState, - icon = if (isPanelExpanded) Icons.Rounded.Archive + icon = if (currentIsPanelExpanded) Icons.Rounded.Archive else Icons.Rounded.Unarchive, onClick = openOrClosePanel, contentDescription = stringResource(string.feat_channel_tooltip_open_panel) @@ -342,7 +356,7 @@ fun ChannelMask( ) Box(Modifier.size(36.dp)) { androidx.compose.animation.AnimatedVisibility( - visible = !isPanelExpanded && adjacentChannels?.prevId != null, + visible = !currentIsPanelExpanded && adjacentChannels?.prevId != null, enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 6 }), exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 6 }), modifier = Modifier.fillMaxSize() @@ -357,7 +371,7 @@ fun ChannelMask( Box(Modifier.size(52.dp)) { androidx.compose.animation.AnimatedVisibility( - visible = !isPanelExpanded && centerRole != MaskCenterRole.Loading, + visible = !currentIsPanelExpanded && centerRole != MaskCenterRole.Loading, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.fillMaxSize() @@ -373,7 +387,7 @@ fun ChannelMask( } Box(Modifier.size(36.dp)) { androidx.compose.animation.AnimatedVisibility( - visible = !isPanelExpanded && adjacentChannels?.nextId != null, + visible = !currentIsPanelExpanded && adjacentChannels?.nextId != null, enter = fadeIn() + slideInHorizontally(initialOffsetX = { it / 6 }), exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it / 6 }), modifier = Modifier.fillMaxSize() @@ -386,15 +400,29 @@ fun ChannelMask( } } }, - control = cwPositionObj?.let { - composableOf { - CwPositionSliderImpl(it.milliseconds, onResetPlayback = onResetPlayback) + control = { + Crossfade( + targetState = cwPositionObj, + modifier = Modifier.align { size: IntSize, space: IntSize, _: LayoutDirection -> + val centerX = (space.width - size.width).toFloat() / 2f + val centerY = (space.height - size.height).toFloat() / 2f + val x = centerX + val y = centerY + playerState.videoSize.height() / 2 + IntOffset(x.fastRoundToInt(), y.fastRoundToInt()) + } + ) { + if (it != null) { + CwPositionSliderImpl( + position = it.milliseconds, + onResetPlayback = onResetPlayback + ) + } } }, footer = composableOf( any { - suggest { !isPanelExpanded } - suggest { !useVertical } + suggest { !currentIsPanelExpanded } + suggest { !currentUseVertical } suggest { playStateDisplayText.isNotEmpty() } suggest { exceptionDisplayText.isNotEmpty() } suggestAll { @@ -411,7 +439,7 @@ fun ChannelMask( .padding(bottom = spacing.small) ) { val alpha by animateFloatAsState( - if (!isPanelExpanded || !useVertical) 1f else 0f + if (!currentIsPanelExpanded || !currentUseVertical) 1f else 0f ) Column(Modifier.alpha(alpha)) { Text( @@ -487,14 +515,14 @@ fun ChannelMask( contentDuration = contentDuration, contentPosition = contentPosition, bufferedPosition = bufferedPosition, - isPanelExpanded = isPanelExpanded, + isPanelExpanded = currentIsPanelExpanded, onBufferedPositionChanged = { bufferedPosition = it maskState.wake() } ) }, - onDimensionChanged = onDimensionChanged, + onPaddingsChanged = onPaddingsChanged, modifier = Modifier.fillMaxSize() ) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMaskUtils.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMaskUtils.kt index 46d0d618..c83c7749 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMaskUtils.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMaskUtils.kt @@ -72,20 +72,28 @@ internal object ChannelMaskUtils { @Composable get() { val context = LocalContext.current val contentResolver = context.contentResolver - val initialValue = Settings.System.getInt( - contentResolver, - Settings.System.ACCELEROMETER_ROTATION - ) == 1 + val initialValue = try { + Settings.System.getInt( + contentResolver, + Settings.System.ACCELEROMETER_ROTATION + ) == 1 + } catch (_: Settings.SettingNotFoundException) { + false + } return produceState(initialValue) { val uri = Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION) val handler = Handler(Looper.getMainLooper()) val observer = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { super.onChange(selfChange) - value = Settings.System.getInt( - contentResolver, - Settings.System.ACCELEROMETER_ROTATION - ) == 1 + value = try { + Settings.System.getInt( + contentResolver, + Settings.System.ACCELEROMETER_ROTATION + ) == 1 + } catch (_: Settings.SettingNotFoundException) { + false + } } } contentResolver.registerContentObserver( diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt index 91013158..af31d56f 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt @@ -56,7 +56,7 @@ import com.m3u.data.database.model.Playlist import com.m3u.i18n.R.string import com.m3u.smartphone.ui.business.channel.components.DlnaDevicesBottomSheet import com.m3u.smartphone.ui.business.channel.components.FormatsBottomSheet -import com.m3u.smartphone.ui.business.channel.components.MaskDimension +import com.m3u.smartphone.ui.business.channel.components.Paddings import com.m3u.smartphone.ui.business.channel.components.MaskGestureValuePanel import com.m3u.smartphone.ui.business.channel.components.PlayerPanel import com.m3u.smartphone.ui.business.channel.components.VerticalGestureArea @@ -120,11 +120,18 @@ fun ChannelRoute( val programmeReminderIds by viewModel.programmeReminderIds.collectAsStateWithLifecycle() var brightness by remember { mutableFloatStateOf(helper.brightness) } + val isSupportBrightnessGesture by remember { derivedStateOf { brightness != -1f } } var speed by remember { mutableFloatStateOf(1f) } var isPipMode by remember { mutableStateOf(false) } var isAutoZappingMode by remember { mutableStateOf(true) } var choosing by remember { mutableStateOf(false) } + val brightnessGesture by preferenceOf(PreferencesKeys.BRIGHTNESS_GESTURE) + val volumeGesture by preferenceOf(PreferencesKeys.VOLUME_GESTURE) + + val brightnessGestureEnabled by remember { derivedStateOf { isSupportBrightnessGesture && brightnessGesture } } + val volumeGestureEnabled by remember { derivedStateOf { volumeGesture } } + val useVertical = PullPanelLayoutDefaults.UseVertical val maskState = rememberMaskState() @@ -173,10 +180,12 @@ fun ChannelRoute( } LaunchedEffect(Unit) { - snapshotFlow { brightness } - .drop(1) - .onEach { helper.brightness = it } - .launchIn(this) + if (isSupportBrightnessGesture) { + snapshotFlow { brightness } + .drop(1) + .onEach { helper.brightness = it } + .launchIn(this) + } snapshotFlow { isBarVisible } .onEach { isBarVisible -> @@ -195,10 +204,12 @@ fun ChannelRoute( .launchIn(this) } - DisposableEffect(Unit) { - val prev = helper.brightness - onDispose { - helper.brightness = prev + if (brightnessGestureEnabled) { + DisposableEffect(Unit) { + val prev = helper.brightness + onDispose { + helper.brightness = prev + } } } @@ -207,12 +218,14 @@ fun ChannelRoute( maskState.intercept(interceptor) } - var dimension: MaskDimension by remember { mutableStateOf(MaskDimension()) } - val onDimensionChanged = { size: MaskDimension -> dimension = size } - val topPadding by animateDpAsState(dimension.top.takeOrElse { 0.dp }.takeIf { isPanelExpanded } - ?: 0.dp) - val bottomPadding by animateDpAsState(dimension.bottom.takeOrElse { 0.dp } - .takeIf { isPanelExpanded } ?: 0.dp) + var currentPaddings: Paddings by remember { mutableStateOf(Paddings()) } + val onPaddingsChanged = { paddings: Paddings -> currentPaddings = paddings } + val topPadding by animateDpAsState( + currentPaddings.top.takeOrElse { 0.dp }.takeIf { isPanelExpanded } ?: 0.dp + ) + val bottomPadding by animateDpAsState( + currentPaddings.bottom.takeOrElse { 0.dp }.takeIf { isPanelExpanded } ?: 0.dp + ) val aspectRatio = with(density) { val source = playerState.videoSize @@ -259,6 +272,7 @@ fun ChannelRoute( } }, onCancelRemindProgramme = viewModel::onCancelRemindProgramme, + onRequestClosed = { pullPanelLayoutState.collapse() } ) }, content = { @@ -287,10 +301,12 @@ fun ChannelRoute( channel = channel, hasTrack = tracks.isNotEmpty(), isPanelExpanded = isPanelExpanded, - volume = volume, - onVolume = viewModel::onVolume, brightness = brightness, onBrightness = { brightness = it }, + volume = volume, + onVolume = viewModel::onVolume, + brightnessGestureEnabled = brightnessGestureEnabled, + volumeGestureEnabled = volumeGestureEnabled, speed = speed, onSpeedUpdated = { viewModel.onSpeedUpdated(it) @@ -305,7 +321,7 @@ fun ChannelRoute( maskState.unlockAll() pullPanelLayoutState.collapse() }, - onDimensionChanged = onDimensionChanged, + onPaddingsChanged = onPaddingsChanged, onAlignment = onAlignment ) }, @@ -354,8 +370,10 @@ private fun ChannelPlayer( isSeriesPlaylist: Boolean, hasTrack: Boolean, isPanelExpanded: Boolean, - volume: Float, brightness: Float, + volume: Float, + brightnessGestureEnabled: Boolean, + volumeGestureEnabled: Boolean, speed: Float, cwPosition: Long, onResetPlayback: () -> Unit, @@ -369,7 +387,7 @@ private fun ChannelPlayer( onNextChannelClick: () -> Unit, onEnterPipMode: () -> Unit, onSpeedUpdated: (Float) -> Unit, - onDimensionChanged: (MaskDimension) -> Unit, + onPaddingsChanged: (Paddings) -> Unit, onAlignment: (size: IntSize, space: IntSize) -> IntOffset, modifier: Modifier = Modifier, ) { @@ -386,8 +404,6 @@ private fun ChannelPlayer( val currentSpeed by rememberUpdatedState(speed) val clipMode by preferenceOf(PreferencesKeys.CLIP_MODE) - val brightnessGesture by preferenceOf(PreferencesKeys.BRIGHTNESS_GESTURE) - val volumeGesture by preferenceOf(PreferencesKeys.VOLUME_GESTURE) val useVertical = with(windowInfo.containerSize) { width < height } @@ -401,8 +417,6 @@ private fun ChannelPlayer( player = playerState.player, clipMode = clipMode ) - var dimension: MaskDimension by remember { mutableStateOf(MaskDimension()) } - val topPadding = with(density) { dimension.top.takeOrElse { 0.dp }.toPx() } Player( state = state, modifier = Modifier @@ -425,7 +439,7 @@ private fun ChannelPlayer( .fillMaxHeight(0.7f) .fillMaxWidth(0.18f) .align(Alignment.CenterStart), - enabled = brightnessGesture + enabled = brightnessGestureEnabled ) VerticalGestureArea( @@ -442,7 +456,7 @@ private fun ChannelPlayer( .align(Alignment.CenterEnd) .fillMaxHeight(0.7f) .fillMaxWidth(0.18f), - enabled = volumeGesture + enabled = volumeGestureEnabled ) ChannelMask( @@ -472,10 +486,7 @@ private fun ChannelPlayer( onSpeedStart = { gesture = MaskGesture.SPEED }, onSpeedEnd = { gesture = null }, gesture = gesture, - onDimensionChanged = { - dimension = it - onDimensionChanged(it) - } + onPaddingsChanged = onPaddingsChanged ) if (gesture != null) { diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerMask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerMask.kt index bd5f4238..11ff3dcb 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerMask.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerMask.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -34,9 +35,8 @@ import com.m3u.smartphone.ui.material.components.mask.MaskState import com.m3u.smartphone.ui.material.model.LocalSpacing @Immutable -data class MaskDimension( +data class Paddings( val top: Dp = Dp.Unspecified, - val middle: Dp = Dp.Unspecified, val bottom: Dp = Dp.Unspecified ) @@ -48,18 +48,18 @@ fun PlayerMask( header: @Composable RowScope.() -> Unit, body: @Composable RowScope.() -> Unit, footer: (@Composable RowScope.() -> Unit)? = null, - control: (@Composable RowScope.() -> Unit)? = null, slider: (@Composable () -> Unit)? = null, - onDimensionChanged: (MaskDimension) -> Unit = {} + control: (@Composable BoxScope.() -> Unit) = {}, + onPaddingsChanged: (Paddings) -> Unit = {} ) { val configuration = LocalConfiguration.current val spacing = LocalSpacing.current val density = LocalDensity.current val windowInsets = WindowInsets.safeDrawing - var size: MaskDimension by remember { + var size: Paddings by remember { mutableStateOf( - MaskDimension( + Paddings( top = with(density) { windowInsets.getTop(density).toDp() }, bottom = with(density) { windowInsets.getBottom(density).toDp() }, ) @@ -69,15 +69,16 @@ fun PlayerMask( Mask( state = state, color = color, - modifier = modifier.windowInsetsPadding(WindowInsets.safeDrawing) + modifier = modifier.windowInsetsPadding(WindowInsets.safeDrawing), + control = control ) { DisposableEffect(Unit) { onDispose { - size = MaskDimension( + size = Paddings( top = with(density) { windowInsets.getTop(density).toDp() }, bottom = with(density) { windowInsets.getBottom(density).toDp() } ) - onDimensionChanged(size) + onPaddingsChanged(size) } } Row( @@ -87,7 +88,7 @@ fun PlayerMask( size = size.copy( top = with(density) { (it.size.height + windowInsets.getTop(density)).toDp() } ) - onDimensionChanged(size) + onPaddingsChanged(size) } .padding(horizontal = spacing.medium), horizontalArrangement = Arrangement.spacedBy( @@ -104,13 +105,7 @@ fun PlayerMask( modifier = Modifier .padding(horizontal = spacing.medium) .fillMaxWidth() - .weight(1f) - .onGloballyPositioned { - size = size.copy( - middle = with(density) { it.size.height.toDp() } - ) - onDimensionChanged(size) - }, + .weight(1f), horizontalArrangement = Arrangement.spacedBy( centerSpacing, Alignment.CenterHorizontally @@ -123,14 +118,6 @@ fun PlayerMask( .systemGestureExclusion() .padding(horizontal = spacing.medium) ) { - control?.let { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Bottom, - content = it - ) - } footer?.let { Row( modifier = Modifier.fillMaxWidth(), @@ -147,7 +134,7 @@ fun PlayerMask( size = size.copy( bottom = with(density) { (it.size.height + windowInsets.getBottom(density)).toDp() } ) - onDimensionChanged(size) + onPaddingsChanged(size) } ) { slider?.invoke() diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt index eaf3ec60..79297827 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt @@ -101,6 +101,7 @@ internal fun PlayerPanel( programmeReminderIds: List, onRemindProgramme: (Programme) -> Unit, onCancelRemindProgramme: (Programme) -> Unit, + onRequestClosed: () -> Unit, ) { val spacing = LocalSpacing.current @@ -134,6 +135,7 @@ internal fun PlayerPanel( programmes = programmes, programmeRange = programmeRange, programmeReminderIds = programmeReminderIds, + onRequestClosed = onRequestClosed, onProgrammePressed = { programme = it animProgramme = it @@ -285,7 +287,8 @@ fun PlayerPanelImpl( programmeRange: ProgrammeRange, programmeReminderIds: List, modifier: Modifier = Modifier, - onProgrammePressed: (Programme) -> Unit + onProgrammePressed: (Programme) -> Unit, + onRequestClosed: () -> Unit, ) { val spacing = LocalSpacing.current Column( @@ -327,7 +330,8 @@ fun PlayerPanelImpl( } Icon( imageVector = Icons.Rounded.Close, - contentDescription = null + contentDescription = null, + modifier = Modifier.clickable { onRequestClosed() } ) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt index b3cace04..00614ea5 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt @@ -167,7 +167,7 @@ fun ForyouRoute( @Composable private fun ForyouScreen( rowCount: Int, - playlists: Resource>, + playlists: Map, subscribingPlaylistUrls: List, refreshingEpgUrls: List, specs: List, @@ -178,7 +178,6 @@ private fun ForyouScreen( onUnsubscribePlaylist: (playlistUrl: String) -> Unit, modifier: Modifier = Modifier ) { - val spacing = LocalSpacing.current val configuration = LocalConfiguration.current val lifecycleOwner = LocalLifecycleOwner.current @@ -209,56 +208,36 @@ private fun ForyouScreen( Box(modifier) { HeadlineBackground() - when (playlists) { - Resource.Loading -> { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - ) - } - - is Resource.Success -> { - val header = @Composable { - RecommendGallery( - specs = specs, - navigateToPlaylist = navigateToPlaylist, - onPlayChannel = onPlayChannel, - onSpecChanged = { spec -> headlineSpec = spec }, - modifier = Modifier.fillMaxWidth() - ) - } - PlaylistGallery( - rowCount = actualRowCount, - playlists = playlists.data, - subscribingPlaylistUrls = subscribingPlaylistUrls, - refreshingEpgUrls = refreshingEpgUrls, - onClick = navigateToPlaylist, - onLongClick = { mediaSheetValue = MediaSheetValue.ForyouScreen(it) }, - header = composableOf(specs.isNotEmpty(), header), - contentPadding = contentPadding, - modifier = Modifier.fillMaxSize() - ) - MediaSheet( - value = mediaSheetValue, - onUnsubscribePlaylist = { - onUnsubscribePlaylist(it.url) - mediaSheetValue = MediaSheetValue.ForyouScreen() - }, - onPlaylistConfiguration = navigateToPlaylistConfiguration, - onDismissRequest = { - mediaSheetValue = MediaSheetValue.ForyouScreen() - } - ) - } - - is Resource.Failure -> { - Text( - text = playlists.message.orEmpty(), - color = MaterialTheme.colorScheme.error, - modifier = Modifier.align(Alignment.Center) - ) - } + val header = @Composable { + RecommendGallery( + specs = specs, + navigateToPlaylist = navigateToPlaylist, + onPlayChannel = onPlayChannel, + onSpecChanged = { spec -> headlineSpec = spec }, + modifier = Modifier.fillMaxWidth() + ) } + PlaylistGallery( + rowCount = actualRowCount, + playlists = playlists, + subscribingPlaylistUrls = subscribingPlaylistUrls, + refreshingEpgUrls = refreshingEpgUrls, + onClick = navigateToPlaylist, + onLongClick = { mediaSheetValue = MediaSheetValue.ForyouScreen(it) }, + header = composableOf(specs.isNotEmpty(), header), + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize() + ) + MediaSheet( + value = mediaSheetValue, + onUnsubscribePlaylist = { + onUnsubscribePlaylist(it.url) + mediaSheetValue = MediaSheetValue.ForyouScreen() + }, + onPlaylistConfiguration = navigateToPlaylistConfiguration, + onDismissRequest = { + mediaSheetValue = MediaSheetValue.ForyouScreen() + } + ) } } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt index 5e3d15fa..58879cb1 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,7 +24,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.m3u.data.database.model.DataSource import com.m3u.data.database.model.Playlist -import com.m3u.data.database.model.PlaylistWithCount import com.m3u.data.database.model.epgUrlsOrXtreamXmlUrl import com.m3u.data.database.model.fromLocal import com.m3u.data.database.model.type @@ -45,7 +43,7 @@ import kotlin.math.absoluteValue @Composable internal fun PlaylistGallery( rowCount: Int, - playlists: List, + playlists: Map, subscribingPlaylistUrls: List, refreshingEpgUrls: List, onClick: (Playlist) -> Unit, @@ -94,12 +92,9 @@ internal fun PlaylistGallery( header() } } - itemsIndexed( - items = playlists, - key = { _, playlistCount -> playlistCount.playlist.url } - ) { index, playlistCount -> - val playlist = playlistCount.playlist - val count = playlistCount.count + val entries = playlists.entries.toList() + items(entries.size) { index -> + val (playlist, count) = entries[index] val subscribing = playlist.url in subscribingPlaylistUrls val refreshing = playlist .epgUrlsOrXtreamXmlUrl() diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt index 404575dc..5db4b9b7 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp @@ -61,6 +63,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.PagingData import com.google.accompanist.permissions.rememberPermissionState import com.m3u.business.playlist.PlaylistViewModel import com.m3u.core.architecture.preferences.PreferencesKeys @@ -100,6 +103,8 @@ import com.m3u.smartphone.ui.material.model.LocalHazeState import com.m3u.smartphone.ui.material.model.LocalSpacing import dev.chrisbanes.haze.hazeSource import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -127,7 +132,7 @@ internal fun PlaylistRoute( val playlistUrl by viewModel.playlistUrl.collectAsStateWithLifecycle() val playlist by viewModel.playlist.collectAsStateWithLifecycle() - val channels by viewModel.channels.collectAsStateWithLifecycle( + val channels: Map>> by viewModel.channels.collectAsStateWithLifecycle( minActiveState = Lifecycle.State.RESUMED ) @@ -217,7 +222,7 @@ internal fun PlaylistRoute( onQuery = { viewModel.query.value = it }, rowCount = rowCount, zapping = zapping, - categoryWithChannels = channels, + channels = channels, pinnedCategories = pinnedCategories, onPinOrUnpinCategory = { viewModel.onPinOrUnpinCategory(it) }, onHideCategory = { viewModel.onHideCategory(it) }, @@ -325,7 +330,7 @@ private fun PlaylistScreen( onQuery: (String) -> Unit, rowCount: Int, zapping: Channel?, - categoryWithChannels: List, + channels: Map>>, pinnedCategories: List, onPinOrUnpinCategory: (String) -> Unit, onHideCategory: (String) -> Unit, @@ -400,7 +405,7 @@ private fun PlaylistScreen( } } - val categories = remember(categoryWithChannels) { categoryWithChannels.map { it.category } } + val categories = remember(channels) { channels.map { it.key } } var category by remember(categories) { mutableStateOf(categories.firstOrNull().orEmpty()) } val state = rememberLazyStaggeredGridState() @@ -425,6 +430,8 @@ private fun PlaylistScreen( } BackHandler(isExpanded) { isExpanded = false } + var targetPageIndex: Event by remember { mutableStateOf(Event.Handled()) } + val tabs = @Composable { PlaylistTabRow( selectedCategory = category, @@ -432,7 +439,13 @@ private fun PlaylistScreen( isExpanded = isExpanded, bottomContentPadding = contentPadding only WindowInsetsSides.Bottom, onExpanded = { isExpanded = !isExpanded }, - onCategoryChanged = { category = it }, + onCategoryChanged = { + category = it + targetPageIndex = categories.indexOf(it) + .takeIf { it != -1 } + ?.let { eventOf(it) } + ?: Event.Handled() + }, pinnedCategories = pinnedCategories, onPinOrUnpinCategory = onPinOrUnpinCategory, onHideCategory = onHideCategory @@ -440,28 +453,42 @@ private fun PlaylistScreen( } val gallery = @Composable { - val channel = remember(categoryWithChannels, category) { - categoryWithChannels.find { it.category == category } + val pagerState = rememberPagerState { channels.size } + val entries = channels.entries.toList() + LaunchedEffect(entries) { + snapshotFlow { pagerState.settledPage } + .collectLatest { index -> + category = entries.getOrNull(index)?.key.orEmpty() + } } - ChannelGallery( - state = state, - rowCount = actualRowCount, - categoryWithChannels = channel, - zapping = zapping, - recently = sort == Sort.RECENTLY, - isVodOrSeriesPlaylist = isVodPlaylist || isSeriesPlaylist, - onClick = onPlayChannel, - contentPadding = contentPadding.minus(contentPadding.only(WindowInsetsSides.Top)), - onLongClick = { - mediaSheetValue = MediaSheetValue.PlaylistScreen(it) - }, - getProgrammeCurrently = getProgrammeCurrently, - reloadThumbnail = reloadThumbnail, - syncThumbnail = syncThumbnail, + EventHandler(targetPageIndex) { + pagerState.scrollToPage(it) + } + HorizontalPager( + state = pagerState, modifier = Modifier .hazeSource(LocalHazeState.current) .background(MaterialTheme.colorScheme.surfaceContainerHighest) - ) + ) { index -> + val (_, channels) = entries[index] + + ChannelGallery( + state = state, + rowCount = actualRowCount, + channels = channels, + zapping = zapping, + recently = sort == Sort.RECENTLY, + isVodOrSeriesPlaylist = isVodPlaylist || isSeriesPlaylist, + onClick = onPlayChannel, + contentPadding = contentPadding.minus(contentPadding.only(WindowInsetsSides.Top)), + onLongClick = { + mediaSheetValue = MediaSheetValue.PlaylistScreen(it) + }, + getProgrammeCurrently = getProgrammeCurrently, + reloadThumbnail = reloadThumbnail, + syncThumbnail = syncThumbnail, + ) + } } Column( Modifier diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt index ec1228d0..7f8b6925 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelGallery.kt @@ -21,23 +21,24 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import com.m3u.data.database.model.Channel import com.m3u.data.database.model.Programme -import com.m3u.business.playlist.PlaylistViewModel import com.m3u.core.architecture.preferences.PreferencesKeys import com.m3u.core.architecture.preferences.preferenceOf import com.m3u.core.foundation.components.CircularProgressIndicator import com.m3u.smartphone.ui.material.ktx.plus import com.m3u.smartphone.ui.material.model.LocalSpacing import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlin.time.Duration.Companion.milliseconds @Composable internal fun ChannelGallery( state: LazyStaggeredGridState, rowCount: Int, - categoryWithChannels: PlaylistViewModel.CategoryWithChannels?, + channels: Flow>, zapping: Channel?, recently: Boolean, isVodOrSeriesPlaylist: Boolean, @@ -47,8 +48,7 @@ internal fun ChannelGallery( reloadThumbnail: suspend (channelUrl: String) -> Uri?, syncThumbnail: suspend (channelUrl: String) -> Uri?, modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), - showScrollbar: Boolean = true + contentPadding: PaddingValues = PaddingValues(0.dp) ) { val spacing = LocalSpacing.current @@ -64,7 +64,7 @@ internal fun ChannelGallery( } } - val channels = categoryWithChannels?.channels?.collectAsLazyPagingItems() + val channels = channels.collectAsLazyPagingItems() val currentGetProgrammeCurrently by rememberUpdatedState(getProgrammeCurrently) val currentReloadThumbnail by rememberUpdatedState(reloadThumbnail) @@ -89,8 +89,8 @@ internal fun ChannelGallery( .fillMaxSize() .weight(1f) ) { - items(channels?.itemCount ?: 0) { index -> - val channel = channels?.get(index) + items(channels.itemCount) { index -> + val channel = channels[index] if (channel != null) { val programme: Programme? by produceState( initialValue = null, diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt index 53967827..3a45ddfd 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt @@ -134,10 +134,10 @@ internal fun PlaylistTabRow( } } } - LaunchedEffect(Unit) { + LaunchedEffect(selectedCategory) { val index = categories.indexOf(selectedCategory) if (index != -1) { - state.scrollToItem(index) + state.animateScrollToItem(index) } } val categoriesContent: LazyListScope.() -> Unit = { diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/Scaffold.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/Scaffold.kt index 0cfe2071..54472a14 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/Scaffold.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/Scaffold.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.util.fastMaxOfOrNull import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.PagingData -import com.m3u.business.playlist.PlaylistViewModel import com.m3u.core.foundation.ui.composableOf import com.m3u.data.database.model.Channel import com.m3u.data.service.MediaCommand @@ -265,7 +264,7 @@ internal fun MainContent( ChannelGallery( state = state, rowCount = 1, - categoryWithChannels = PlaylistViewModel.CategoryWithChannels("", channels), + channels = channels, zapping = null, recently = false, isVodOrSeriesPlaylist = false, @@ -279,7 +278,6 @@ internal fun MainContent( getProgrammeCurrently = { null }, reloadThumbnail = { null }, syncThumbnail = { null }, - showScrollbar = false, contentPadding = WindowInsets.ime.asPaddingValues() ) } diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt index 388346bd..7246e66f 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/helper/Helper.kt @@ -82,10 +82,14 @@ class Helper(private val activity: ComponentActivity) { ) } var brightness: Float - get() = Settings.System.getInt( - activity.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f + get() = try { + Settings.System.getInt( + activity.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Settings.SettingNotFoundException) { + -1f + } set(value) { activity.window.attributes = activity.window.attributes.apply { screenBrightness = value diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/Mask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/Mask.kt index c2caa03e..5c1dbb94 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/Mask.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/Mask.kt @@ -3,6 +3,8 @@ package com.m3u.smartphone.ui.material.components.mask import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +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.runtime.Composable @@ -19,7 +21,8 @@ fun Mask( state: MaskState, color: Color, modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit + control: @Composable BoxScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, ) { val focusRequester = remember { FocusRequester() } AnimatedVisibility( @@ -30,12 +33,16 @@ fun Mask( LaunchedEffect(Unit) { focusRequester.requestFocus() } - Column( + Box( modifier = Modifier .focusRequester(focusRequester) .drawBehind { drawRect(color) } - .then(modifier), - content = content - ) + .then(modifier) + ) { + Column( + content = content + ) + control() + } } } diff --git a/app/tv/src/main/java/com/m3u/tv/screens/foryou/ForyouScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/foryou/ForyouScreen.kt index ae189737..3839fd21 100644 --- a/app/tv/src/main/java/com/m3u/tv/screens/foryou/ForyouScreen.kt +++ b/app/tv/src/main/java/com/m3u/tv/screens/foryou/ForyouScreen.kt @@ -12,14 +12,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.text.font.FontWeight @@ -36,10 +34,8 @@ import androidx.tv.material3.Text import com.m3u.business.foryou.ForyouViewModel import com.m3u.business.foryou.Recommend import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape -import com.m3u.core.foundation.components.CircularProgressIndicator import com.m3u.core.foundation.ui.SugarColors -import com.m3u.core.wrapper.Resource -import com.m3u.data.database.model.PlaylistWithCount +import com.m3u.data.database.model.Playlist import com.m3u.tv.screens.dashboard.rememberChildPadding import com.m3u.tv.theme.LexendExa @@ -51,40 +47,24 @@ fun ForyouScreen( isTopBarVisible: Boolean, viewModel: ForyouViewModel = hiltViewModel(), ) { - val playlists: Resource> by viewModel.playlists.collectAsStateWithLifecycle() + val playlists: Map by viewModel.playlists.collectAsStateWithLifecycle() val specs: List by viewModel.specs.collectAsStateWithLifecycle() Box(Modifier.fillMaxSize()) { - when (val playlists = playlists) { - Resource.Loading -> { - CircularProgressIndicator( - Modifier.align(Alignment.Center) - ) - } - - is Resource.Success -> { - Catalog( - playlists = playlists.data, - specs = specs, - onScroll = onScroll, - navigateToPlaylist = navigateToPlaylist, - navigateToChannel = navigateToChannel, - isTopBarVisible = isTopBarVisible, - modifier = Modifier.fillMaxSize() - ) - } - - is Resource.Failure -> { - Text( - text = playlists.message.orEmpty() - ) - } - } + Catalog( + playlists = playlists, + specs = specs, + onScroll = onScroll, + navigateToPlaylist = navigateToPlaylist, + navigateToChannel = navigateToChannel, + isTopBarVisible = isTopBarVisible, + modifier = Modifier.fillMaxSize() + ) } } @Composable private fun Catalog( - playlists: List, + playlists: Map, specs: List, onScroll: (isTopBarVisible: Boolean) -> Unit, navigateToPlaylist: (playlistUrl: String) -> Unit, @@ -152,7 +132,9 @@ private fun Catalog( .padding(top = 16.dp), contentPadding = PaddingValues(start = startPadding, end = endPadding) ) { - items(playlists) { (playlist, _) -> + val entries = playlists.entries.toList() + items(entries.size) { + val (playlist, _) = entries[it] val (color, contentColor) = remember { SugarColors.entries.random() } diff --git a/app/tv/src/main/java/com/m3u/tv/screens/playlist/ChannelGallery.kt b/app/tv/src/main/java/com/m3u/tv/screens/playlist/ChannelGallery.kt index 4c31de35..4e4e6373 100644 --- a/app/tv/src/main/java/com/m3u/tv/screens/playlist/ChannelGallery.kt +++ b/app/tv/src/main/java/com/m3u/tv/screens/playlist/ChannelGallery.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.VisibilityOff @@ -50,6 +49,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.tv.material3.Border import androidx.tv.material3.CardDefaults @@ -62,16 +62,16 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest -import com.m3u.business.playlist.PlaylistViewModel import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape import com.m3u.core.foundation.ui.thenIf import com.m3u.data.database.model.Channel import com.m3u.tv.theme.JetStreamBorderWidth import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlin.time.Duration.Companion.milliseconds fun LazyListScope.channelGallery( - channels: List, + channels: Map>>, pinnedCategories: List, onPinOrUnpinCategory: (String) -> Unit, onHideCategory: (String) -> Unit, @@ -81,7 +81,9 @@ fun LazyListScope.channelGallery( reloadThumbnail: suspend (channelUrl: String) -> Uri?, syncThumbnail: suspend (channelUrl: String) -> Uri?, ) { - itemsIndexed(channels, key = { _, (category, _) -> category }) { i, (category, channels) -> + val entries = channels.entries.toList() + items(entries.size) { index -> + val (category, channels) = entries[index] var hasFocus by remember { mutableStateOf(false) } Column { val pagingChannels = channels.collectAsLazyPagingItems() diff --git a/app/tv/src/main/java/com/m3u/tv/screens/playlist/PlaylistScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/playlist/PlaylistScreen.kt index f2b7d055..3ba99340 100644 --- a/app/tv/src/main/java/com/m3u/tv/screens/playlist/PlaylistScreen.kt +++ b/app/tv/src/main/java/com/m3u/tv/screens/playlist/PlaylistScreen.kt @@ -11,16 +11,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.PagingData import com.m3u.business.playlist.PlaylistViewModel import com.m3u.data.database.model.Channel import com.m3u.tv.screens.dashboard.rememberChildPadding +import kotlinx.coroutines.flow.Flow @Composable fun PlaylistScreen( onChannelClick: (channel: Channel) -> Unit, viewModel: PlaylistViewModel = hiltViewModel(), ) { - val channels by viewModel.channels.collectAsStateWithLifecycle() + val channels: Map>> by viewModel.channels.collectAsStateWithLifecycle() val pinnedCategories by viewModel.pinnedCategories.collectAsStateWithLifecycle() Catalog( channels = channels, @@ -36,7 +38,7 @@ fun PlaylistScreen( @Composable private fun Catalog( - channels: List, + channels: Map>>, pinnedCategories: List, onPinOrUnpinCategory: (String) -> Unit, onHideCategory: (String) -> Unit, diff --git a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt index 06b25819..c9a79277 100644 --- a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt +++ b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt @@ -12,12 +12,10 @@ import com.m3u.core.architecture.preferences.PreferencesKeys import com.m3u.core.architecture.preferences.Settings import com.m3u.core.architecture.preferences.flowOf import com.m3u.core.wrapper.Resource -import com.m3u.core.wrapper.asResource import com.m3u.core.wrapper.mapResource import com.m3u.core.wrapper.resource import com.m3u.data.database.model.Channel import com.m3u.data.database.model.Playlist -import com.m3u.data.database.model.PlaylistWithCount import com.m3u.data.parser.xtream.XtreamChannelInfo import com.m3u.data.repository.channel.ChannelRepository import com.m3u.data.repository.playlist.PlaylistRepository @@ -54,15 +52,13 @@ class ForyouViewModel @Inject constructor( ) : ViewModel() { private val logger = delegate.install(Profiles.VIEWMODEL_FORYOU) - val playlists: StateFlow>> = - playlistRepository - .observeAllCounts() - .asResource() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = Resource.Loading - ) + val playlists: StateFlow> = playlistRepository + .observeAllCounts() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyMap() + ) val subscribingPlaylistUrls: StateFlow> = workManager.getWorkInfosFlow( diff --git a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt index a907c1b9..ebdda020 100644 --- a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt +++ b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt @@ -243,12 +243,6 @@ class PlaylistViewModel @Inject constructor( val categories: List, ) - @Immutable - data class CategoryWithChannels( - val category: String, - val channels: Flow>, - ) - @OptIn(FlowPreview::class) private val categories: StateFlow> = flatmapCombined(playlistUrl, query, sort) { playlistUrl, query, sort -> @@ -267,7 +261,7 @@ class PlaylistViewModel @Inject constructor( started = SharingStarted.Lazily ) - val channels: StateFlow> = combine( + val channels: StateFlow>>> = combine( playlistUrl, categories, query, sort @@ -281,42 +275,36 @@ class PlaylistViewModel @Inject constructor( } .mapLatest { (playlistUrl, query, sort, categories) -> if (sort == Sort.MIXED) { - listOf( - CategoryWithChannels( - category = "", - channels = Pager(PagingConfig(15)) { - channelRepository.pagingAllByPlaylistUrl( - playlistUrl, - "", - query, - sort - ) - } - .flow - .cachedIn(viewModelScope) - ) + mapOf( + "" to Pager(PagingConfig(15)) { + channelRepository.pagingAllByPlaylistUrl( + playlistUrl, + "", + query, + sort + ) + } + .flow + .cachedIn(viewModelScope) ) } else { - categories.map { category -> - CategoryWithChannels( - category = category, - channels = Pager(PagingConfig(15)) { - channelRepository.pagingAllByPlaylistUrl( - playlistUrl, - category, - query, - sort - ) - } - .flow - .cachedIn(viewModelScope) - ) + categories.associate { category -> + category to Pager(PagingConfig(15)) { + channelRepository.pagingAllByPlaylistUrl( + playlistUrl, + category, + query, + sort + ) + } + .flow + .cachedIn(viewModelScope) } } } .stateIn( scope = viewModelScope, - initialValue = emptyList(), + initialValue = emptyMap(), started = SharingStarted.Lazily ) diff --git a/data/src/main/java/com/m3u/data/database/model/Playlist.kt b/data/src/main/java/com/m3u/data/database/model/Playlist.kt index 9fa8c2b8..30671751 100644 --- a/data/src/main/java/com/m3u/data/database/model/Playlist.kt +++ b/data/src/main/java/com/m3u/data/database/model/Playlist.kt @@ -165,7 +165,6 @@ data class PlaylistWithChannels( val channels: List ) -@Immutable data class PlaylistWithCount( @Embedded val playlist: Playlist, @@ -173,6 +172,10 @@ data class PlaylistWithCount( val count: Int ) +fun Iterable.toMap(): Map = associate { + it.playlist to it.count +} + private object DataSourceSerializer : KSerializer { override fun deserialize(decoder: Decoder): DataSource { return DataSource.of(decoder.decodeString()) diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepository.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepository.kt index 372686af..2e5099e9 100644 --- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepository.kt +++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepository.kt @@ -57,7 +57,7 @@ interface PlaylistRepository { suspend fun onUpdatePlaylistUserAgent(url: String, userAgent: String?) - fun observeAllCounts(): Flow> + fun observeAllCounts(): Flow> suspend fun readEpisodesOrThrow(series: Channel): List diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt index 6cbc80e8..cae3ca87 100644 --- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt +++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt @@ -28,6 +28,7 @@ import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.PlaylistWithChannels import com.m3u.data.database.model.PlaylistWithCount import com.m3u.data.database.model.fromLocal +import com.m3u.data.database.model.toMap import com.m3u.data.parser.m3u.M3UData import com.m3u.data.parser.m3u.M3UParser import com.m3u.data.parser.m3u.toChannel @@ -577,9 +578,10 @@ internal class PlaylistRepositoryImpl @Inject constructor( playlistDao.updateUserAgent(url, userAgent) } - override fun observeAllCounts(): Flow> = + override fun observeAllCounts(): Flow> = playlistDao.observeAllCounts() - .catch { emit(emptyList()) } + .map { it.toMap() } + .catch { emit(emptyMap()) } override suspend fun readEpisodesOrThrow(series: Channel): List { val playlist = checkNotNull(get(series.playlistUrl)) { "playlist is not exist" }