Skip to content

Commit d608b78

Browse files
committed
Add enterprise-level smooth scrolling and Content Type Mode features
Features Added: - Content Type Mode: Display Live TV, Movies, and Series as separate cards for Xtream playlists - Enterprise smooth scrolling for category list (first column) with 20% threshold zones - Enterprise smooth scrolling for channel list (second column) with intelligent bring-into-view - Conditional live preview: Only shows for Live TV content, empty space for Movies/Series - White text on selected items for better visibility against purple background UI Improvements: - Smooth navigation that only scrolls when items are near viewport edges - Middle 60% "no scroll zone" prevents jarring jumps during navigation - Content Type Mode toggle in Settings with Xtream-only warning Technical Details: - Implemented threshold-based scrolling algorithm - Added CONTENT_TYPE_MODE preference key - Created ContentTypeCardsRow and ContentTypeCard composables - Enhanced CategoryList and ChannelList with smart scroll behavior - Added playlist type detection for conditional UI rendering
1 parent 2939c42 commit d608b78

File tree

7 files changed

+1183
-85
lines changed

7 files changed

+1183
-85
lines changed

app/tv/src/main/java/com/m3u/tv/screens/foryou/ForyouScreen.kt

Lines changed: 236 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
package com.m3u.tv.screens.foryou
22

33
import androidx.compose.foundation.BorderStroke
4+
import androidx.compose.foundation.background
45
import androidx.compose.foundation.focusGroup
6+
import androidx.compose.foundation.layout.Arrangement
57
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.Column
69
import androidx.compose.foundation.layout.PaddingValues
10+
import androidx.compose.foundation.layout.Spacer
711
import androidx.compose.foundation.layout.fillMaxSize
812
import androidx.compose.foundation.layout.fillMaxWidth
913
import androidx.compose.foundation.layout.height
1014
import androidx.compose.foundation.layout.heightIn
1115
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.size
1217
import androidx.compose.foundation.layout.width
1318
import androidx.compose.foundation.lazy.LazyColumn
1419
import androidx.compose.foundation.lazy.LazyRow
1520
import androidx.compose.foundation.lazy.rememberLazyListState
21+
import androidx.compose.material.icons.Icons
22+
import androidx.compose.material.icons.rounded.LiveTv
23+
import androidx.compose.material.icons.rounded.Movie
24+
import androidx.compose.material.icons.rounded.Tv
1625
import androidx.compose.runtime.Composable
1726
import androidx.compose.runtime.LaunchedEffect
1827
import androidx.compose.runtime.derivedStateOf
1928
import androidx.compose.runtime.getValue
2029
import androidx.compose.runtime.remember
30+
import androidx.compose.ui.Alignment
2131
import androidx.compose.ui.Modifier
2232
import androidx.compose.ui.graphics.Color
2333
import androidx.compose.ui.text.font.FontWeight
@@ -27,15 +37,22 @@ import androidx.compose.ui.unit.sp
2737
import androidx.hilt.navigation.compose.hiltViewModel
2838
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2939
import androidx.tv.material3.Border
40+
import androidx.tv.material3.Card
3041
import androidx.tv.material3.CardDefaults
3142
import androidx.tv.material3.CompactCard
43+
import androidx.tv.material3.ExperimentalTvMaterial3Api
44+
import androidx.tv.material3.Icon
3245
import androidx.tv.material3.MaterialTheme
3346
import androidx.tv.material3.Text
3447
import com.m3u.business.foryou.ForyouViewModel
3548
import com.m3u.business.foryou.Recommend
49+
import com.m3u.core.architecture.preferences.PreferencesKeys
50+
import com.m3u.core.architecture.preferences.preferenceOf
3651
import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
3752
import com.m3u.core.foundation.ui.SugarColors
53+
import com.m3u.data.database.model.DataSource
3854
import com.m3u.data.database.model.Playlist
55+
import com.m3u.data.database.model.type
3956
import com.m3u.tv.screens.dashboard.rememberChildPadding
4057
import com.m3u.tv.theme.LexendExa
4158

@@ -72,7 +89,7 @@ private fun Catalog(
7289
modifier: Modifier = Modifier,
7390
isTopBarVisible: Boolean = true,
7491
) {
75-
92+
val contentTypeMode by preferenceOf(PreferencesKeys.CONTENT_TYPE_MODE)
7693
val lazyListState = rememberLazyListState()
7794
val childPadding = rememberChildPadding()
7895

@@ -123,65 +140,228 @@ private fun Catalog(
123140
}
124141

125142
item(contentType = "PlaylistsRow") {
126-
val startPadding: Dp = rememberChildPadding().start
127-
val endPadding: Dp = rememberChildPadding().end
128-
val shape = AbsoluteSmoothCornerShape(16.dp, 100)
129-
LazyRow(
130-
modifier = Modifier
131-
.focusGroup()
132-
.padding(top = 16.dp),
133-
contentPadding = PaddingValues(start = startPadding, end = endPadding)
134-
) {
135-
val entries = playlists.entries.toList()
136-
items(entries.size) {
137-
val (playlist, _) = entries[it]
138-
val (color, contentColor) = remember {
139-
SugarColors.entries.random()
140-
}
141-
CompactCard(
142-
onClick = { navigateToPlaylist(playlist.url) },
143-
title = {
144-
Text(
145-
text = playlist.title,
146-
modifier = Modifier.padding(16.dp),
147-
fontSize = 36.sp,
148-
lineHeight = 36.sp,
149-
fontWeight = FontWeight.Bold,
150-
fontFamily = LexendExa
151-
)
152-
},
153-
colors = CardDefaults.compactCardColors(
154-
containerColor = color,
155-
contentColor = MaterialTheme.colorScheme.background
156-
),
157-
shape = CardDefaults.shape(shape),
158-
border = CardDefaults.border(
159-
border = Border(
160-
BorderStroke(
161-
width = 2.dp,
162-
color = MaterialTheme.colorScheme.border
163-
),
164-
shape = shape
165-
),
166-
focusedBorder = Border(
167-
BorderStroke(width = 4.dp, color = Color.White),
168-
shape = shape
143+
if (contentTypeMode) {
144+
// Show Content Type Cards (Live TV, Movies, Series)
145+
// Filter to only show Xtream playlists with specific types
146+
val xtreamPlaylists = playlists.keys.filter {
147+
it.source == DataSource.Xtream && it.type != null
148+
}
149+
150+
ContentTypeCardsRow(
151+
playlists = xtreamPlaylists.associateWith { playlists[it] ?: 0 },
152+
navigateToPlaylist = navigateToPlaylist,
153+
modifier = Modifier.padding(top = 16.dp)
154+
)
155+
} else {
156+
// Show Traditional Playlist Cards
157+
val startPadding: Dp = rememberChildPadding().start
158+
val endPadding: Dp = rememberChildPadding().end
159+
val shape = AbsoluteSmoothCornerShape(16.dp, 100)
160+
LazyRow(
161+
modifier = Modifier
162+
.focusGroup()
163+
.padding(top = 16.dp),
164+
contentPadding = PaddingValues(start = startPadding, end = endPadding)
165+
) {
166+
val entries = playlists.entries.toList()
167+
items(entries.size) {
168+
val (playlist, _) = entries[it]
169+
val (color, contentColor) = remember {
170+
SugarColors.entries.random()
171+
}
172+
CompactCard(
173+
onClick = { navigateToPlaylist(playlist.url) },
174+
title = {
175+
Text(
176+
text = playlist.title,
177+
modifier = Modifier.padding(16.dp),
178+
fontSize = 36.sp,
179+
lineHeight = 36.sp,
180+
fontWeight = FontWeight.Bold,
181+
fontFamily = LexendExa
182+
)
183+
},
184+
colors = CardDefaults.compactCardColors(
185+
containerColor = color,
186+
contentColor = MaterialTheme.colorScheme.background
169187
),
170-
pressedBorder = Border(
171-
BorderStroke(
172-
width = 4.dp,
173-
color = MaterialTheme.colorScheme.border
188+
shape = CardDefaults.shape(shape),
189+
border = CardDefaults.border(
190+
border = Border(
191+
BorderStroke(
192+
width = 2.dp,
193+
color = MaterialTheme.colorScheme.border
194+
),
195+
shape = shape
196+
),
197+
focusedBorder = Border(
198+
BorderStroke(width = 4.dp, color = Color.White),
199+
shape = shape
174200
),
175-
shape = shape
176-
)
177-
),
178-
image = {},
179-
modifier = Modifier
180-
.width(265.dp)
181-
.heightIn(min = 130.dp)
182-
)
201+
pressedBorder = Border(
202+
BorderStroke(
203+
width = 4.dp,
204+
color = MaterialTheme.colorScheme.border
205+
),
206+
shape = shape
207+
)
208+
),
209+
image = {},
210+
modifier = Modifier
211+
.width(265.dp)
212+
.heightIn(min = 130.dp)
213+
)
214+
}
183215
}
184216
}
185217
}
186218
}
187219
}
220+
221+
@OptIn(ExperimentalTvMaterial3Api::class)
222+
@Composable
223+
private fun ContentTypeCardsRow(
224+
playlists: Map<Playlist, Int>,
225+
navigateToPlaylist: (playlistUrl: String) -> Unit,
226+
modifier: Modifier = Modifier
227+
) {
228+
val startPadding: Dp = rememberChildPadding().start
229+
val endPadding: Dp = rememberChildPadding().end
230+
231+
// Find type-specific Xtream playlists
232+
val livePlaylist = playlists.keys.firstOrNull {
233+
it.source == DataSource.Xtream && it.type == DataSource.Xtream.TYPE_LIVE
234+
}
235+
val vodPlaylist = playlists.keys.firstOrNull {
236+
it.source == DataSource.Xtream && it.type == DataSource.Xtream.TYPE_VOD
237+
}
238+
val seriesPlaylist = playlists.keys.firstOrNull {
239+
it.source == DataSource.Xtream && it.type == DataSource.Xtream.TYPE_SERIES
240+
}
241+
242+
if (livePlaylist == null && vodPlaylist == null && seriesPlaylist == null) {
243+
// Show warning if no type-specific Xtream playlists found
244+
Box(
245+
modifier = modifier
246+
.fillMaxWidth()
247+
.padding(horizontal = startPadding, vertical = 16.dp)
248+
.background(
249+
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
250+
MaterialTheme.shapes.medium
251+
)
252+
.padding(24.dp),
253+
contentAlignment = Alignment.Center
254+
) {
255+
Text(
256+
text = "⚠️ Content Type Mode requires Xtream Codes playlist.\nPlease add an Xtream playlist or disable Content Type Mode in Settings.",
257+
style = MaterialTheme.typography.bodyLarge,
258+
color = MaterialTheme.colorScheme.error,
259+
fontWeight = FontWeight.Medium
260+
)
261+
}
262+
return
263+
}
264+
265+
LazyRow(
266+
modifier = modifier.focusGroup(),
267+
contentPadding = PaddingValues(start = startPadding, end = endPadding),
268+
horizontalArrangement = Arrangement.spacedBy(16.dp)
269+
) {
270+
// Live TV Card
271+
if (livePlaylist != null) {
272+
item {
273+
ContentTypeCard(
274+
title = "Live TV",
275+
subtitle = "${playlists[livePlaylist] ?: 0} channels",
276+
icon = Icons.Rounded.LiveTv,
277+
containerColor = Color(0xFF10B981), // Teal/Green
278+
onClick = { navigateToPlaylist(livePlaylist.url) }
279+
)
280+
}
281+
}
282+
283+
// Movies Card
284+
if (vodPlaylist != null) {
285+
item {
286+
ContentTypeCard(
287+
title = "Movies",
288+
subtitle = "${playlists[vodPlaylist] ?: 0} movies",
289+
icon = Icons.Rounded.Movie,
290+
containerColor = Color(0xFFA855F7), // Purple
291+
onClick = { navigateToPlaylist(vodPlaylist.url) }
292+
)
293+
}
294+
}
295+
296+
// Series Card
297+
if (seriesPlaylist != null) {
298+
item {
299+
ContentTypeCard(
300+
title = "Series",
301+
subtitle = "${playlists[seriesPlaylist] ?: 0} series",
302+
icon = Icons.Rounded.Tv,
303+
containerColor = Color(0xFFF97316), // Orange
304+
onClick = { navigateToPlaylist(seriesPlaylist.url) }
305+
)
306+
}
307+
}
308+
}
309+
}
310+
311+
@OptIn(ExperimentalTvMaterial3Api::class)
312+
@Composable
313+
private fun ContentTypeCard(
314+
title: String,
315+
subtitle: String,
316+
icon: androidx.compose.ui.graphics.vector.ImageVector,
317+
containerColor: Color,
318+
onClick: () -> Unit,
319+
modifier: Modifier = Modifier
320+
) {
321+
Card(
322+
onClick = onClick,
323+
modifier = modifier
324+
.width(320.dp)
325+
.height(180.dp),
326+
colors = CardDefaults.colors(
327+
containerColor = containerColor,
328+
contentColor = Color.White
329+
),
330+
shape = CardDefaults.shape(MaterialTheme.shapes.medium),
331+
border = CardDefaults.border(
332+
focusedBorder = Border(
333+
BorderStroke(4.dp, Color.White),
334+
shape = MaterialTheme.shapes.medium
335+
)
336+
)
337+
) {
338+
Box(
339+
modifier = Modifier.fillMaxSize(),
340+
contentAlignment = Alignment.Center
341+
) {
342+
Column(
343+
horizontalAlignment = Alignment.CenterHorizontally,
344+
verticalArrangement = Arrangement.Center
345+
) {
346+
Icon(
347+
imageVector = icon,
348+
contentDescription = title,
349+
modifier = Modifier.size(64.dp),
350+
tint = Color.White
351+
)
352+
Spacer(modifier = Modifier.height(12.dp))
353+
Text(
354+
text = title,
355+
style = MaterialTheme.typography.titleLarge,
356+
fontWeight = FontWeight.Bold,
357+
color = Color.White
358+
)
359+
Text(
360+
text = subtitle,
361+
style = MaterialTheme.typography.bodyMedium,
362+
color = Color.White.copy(alpha = 0.8f)
363+
)
364+
}
365+
}
366+
}
367+
}

0 commit comments

Comments
 (0)