New Release Card.

This commit is contained in:
oxy
2024-06-09 16:21:11 +08:00
parent f0a8dbfd32
commit f675604864
14 changed files with 276 additions and 79 deletions

View File

@ -15,6 +15,7 @@ object Profiles {
val REPOS_PROGRAMME = Profile("repos-programme")
val REPOS_TELEVISION = Profile("repos-television")
val REPOS_MEDIA = Profile("repos-media")
val REPOS_OTHER = Profile("repos-other")
val PARSER_M3U = Profile("parser-m3u")
val PARSER_XTREAM = Profile("parser-xtream")

View File

@ -9,7 +9,7 @@ val Double.MB: DataUnit.MB get() = DataUnit.MB(this)
val Double.KB: DataUnit.KB get() = DataUnit.KB(this)
@Immutable
sealed class DataUnit {
sealed class DataUnit : Comparable<DataUnit> {
data class GB(val value: Double) : DataUnit() {
override fun toString(): String = "${value.toInt()} GB"
}
@ -22,6 +22,18 @@ sealed class DataUnit {
override fun toString(): String = "${value.toInt()} KB"
}
val length: Double
get() = when (this) {
is GB -> 1024 * 1024 * 1024 * value
is MB -> 1024 * 1024 * value
is KB -> 1024 * value
Unspecified -> 0.0
}
override fun compareTo(other: DataUnit): Int {
return this.length.compareTo(other.length)
}
companion object {
private const val KB: Long = 1024
private const val MB = KB * 1024

View File

@ -11,6 +11,8 @@ import com.m3u.data.repository.media.MediaRepositoryImpl
import com.m3u.data.repository.playlist.PlaylistRepositoryImpl
import com.m3u.data.repository.programme.ProgrammeRepositoryImpl
import com.m3u.data.repository.channel.ChannelRepositoryImpl
import com.m3u.data.repository.other.OtherRepository
import com.m3u.data.repository.other.OtherRepositoryImpl
import com.m3u.data.repository.television.TelevisionRepositoryImpl
import dagger.Binds
import dagger.Module
@ -50,4 +52,10 @@ internal interface RepositoryModule {
fun bindTelevisionRepository(
repository: TelevisionRepositoryImpl
): TelevisionRepository
@Binds
@Singleton
fun bindOtherRepository(
repositoryImpl: OtherRepositoryImpl
): OtherRepository
}

View File

@ -32,7 +32,7 @@ import javax.inject.Inject
private const val BITMAP_QUALITY = 100
class MediaRepositoryImpl @Inject constructor(
internal class MediaRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
delegate: Logger,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher

View File

@ -0,0 +1,7 @@
package com.m3u.data.repository.other
import com.m3u.data.api.dto.github.Release
interface OtherRepository {
suspend fun getRelease(): Release?
}

View File

@ -0,0 +1,30 @@
package com.m3u.data.repository.other
import com.m3u.core.architecture.Publisher
import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.Profiles
import com.m3u.core.architecture.logger.execute
import com.m3u.core.architecture.logger.install
import com.m3u.core.util.collections.indexOf
import com.m3u.data.api.GithubApi
import com.m3u.data.api.dto.github.Release
import javax.inject.Inject
internal class OtherRepositoryImpl @Inject constructor(
private val githubApi: GithubApi,
private val publisher: Publisher,
delegate: Logger
) : OtherRepository {
private val logger = delegate.install(Profiles.REPOS_OTHER)
override suspend fun getRelease(): Release? {
if (publisher.snapshot) return null
val versionName = publisher.versionName
val releases = logger
.execute { githubApi.releases("oxyroid", "M3UAndroid") }
?: emptyList()
if (releases.isEmpty()) return null
val i = releases.indexOf { it.name == versionName }
// if (i <= 0) return null
return releases.first()
}
}

View File

@ -1,11 +1,5 @@
package com.m3u.feature.favorite.components
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -25,7 +19,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@ -40,6 +33,7 @@ import com.m3u.i18n.R.string
import com.m3u.material.ktx.isTelevision
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
@ -131,7 +125,7 @@ private fun RandomTips(
.clip(AbsoluteRoundedCornerShape(spacing.medium))
.clickable(onClick = onClick)
.background(
createPremiumBrush(
Brush.createPremiumBrush(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
)
@ -156,7 +150,7 @@ private fun RandomTips(
modifier = Modifier
.fillMaxWidth()
.background(
createPremiumBrush(
Brush.createPremiumBrush(
TvMaterialTheme.colorScheme.primary,
TvMaterialTheme.colorScheme.tertiary
)
@ -175,33 +169,3 @@ private fun RandomTips(
}
}
}
@Composable
private fun createPremiumBrush(
color1: Color = MaterialTheme.colorScheme.primaryContainer,
color2: Color = MaterialTheme.colorScheme.secondaryContainer
): Brush {
val transition = rememberInfiniteTransition("premium-brush")
val leftColor by transition.animateColor(
initialValue = color1,
targetValue = color2,
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "left"
)
val rightColor by transition.animateColor(
initialValue = color2,
targetValue = color1,
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "right"
)
return Brush.linearGradient(
colors = listOf(leftColor, rightColor)
)
}

View File

@ -34,9 +34,9 @@ import androidx.lifecycle.repeatOnLifecycle
import com.m3u.core.architecture.preferences.hiltPreferences
import com.m3u.core.util.basic.title
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.database.model.Channel
import com.m3u.data.database.model.isSeries
import com.m3u.data.service.MediaCommand
import com.m3u.feature.foryou.components.HeadlineBackground
@ -81,7 +81,7 @@ fun ForyouRoute(
val isPageInfoVisible = root == Destination.Root.Foryou
val playlistCountsResource by viewModel.playlistCountsResource.collectAsStateWithLifecycle()
val recommend by viewModel.recommend.collectAsStateWithLifecycle()
val specs by viewModel.specs.collectAsStateWithLifecycle()
val episodes by viewModel.episodes.collectAsStateWithLifecycle()
val series: Channel? by viewModel.series.collectAsStateWithLifecycle()
@ -112,7 +112,7 @@ fun ForyouRoute(
playlistCountsResource = playlistCountsResource,
subscribingPlaylistUrls = subscribingPlaylistUrls,
refreshingEpgUrls = refreshingEpgUrls,
recommend = recommend,
specs = specs,
rowCount = preferences.rowCount,
contentPadding = contentPadding,
navigateToPlaylist = navigateToPlaylist,
@ -177,7 +177,7 @@ private fun ForyouScreen(
playlistCountsResource: Resource<List<PlaylistWithCount>>,
subscribingPlaylistUrls: List<String>,
refreshingEpgUrls: List<String>,
recommend: Recommend,
specs: List<Recommend.Spec>,
contentPadding: PaddingValues,
navigateToPlaylist: (Playlist) -> Unit,
onClickChannel: (Channel) -> Unit,
@ -202,12 +202,13 @@ private fun ForyouScreen(
}
LaunchedEffect(headlineSpec) {
val currentHeadlineSpec = headlineSpec
val spec = headlineSpec
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
delay(400.milliseconds)
Metadata.headlineUrl = when (currentHeadlineSpec) {
is Recommend.UnseenSpec -> currentHeadlineSpec.channel.cover.orEmpty()
Metadata.headlineUrl = when (spec) {
is Recommend.UnseenSpec -> spec.channel.cover.orEmpty()
is Recommend.DiscoverSpec -> ""
is Recommend.NewRelease -> ""
else -> ""
}
}
@ -228,7 +229,7 @@ private fun ForyouScreen(
val showPlaylist = playlistCountsResource.data.isNotEmpty()
val header = @Composable {
RecommendGallery(
recommend = recommend,
specs = specs,
navigateToPlaylist = navigateToPlaylist,
onClickChannel = onClickChannel,
onSpecChanged = { spec -> headlineSpec = spec },
@ -243,7 +244,7 @@ private fun ForyouScreen(
refreshingEpgUrls = refreshingEpgUrls,
onClick = navigateToPlaylist,
onLongClick = { mediaSheetValue = MediaSheetValue.ForyouScreen(it) },
header = header.takeIf { recommend.isNotEmpty() },
header = header.takeIf { specs.isNotEmpty() },
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize()
)

View File

@ -12,17 +12,20 @@ import com.m3u.core.architecture.logger.Logger
import com.m3u.core.architecture.logger.Profiles
import com.m3u.core.architecture.logger.install
import com.m3u.core.architecture.preferences.Preferences
import com.m3u.core.unit.DataUnit
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.api.dto.github.Release
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.database.model.Channel
import com.m3u.data.parser.xtream.XtreamChannelInfo
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.other.OtherRepository
import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.worker.SubscriptionWorker
import com.m3u.feature.foryou.components.recommend.Recommend
import dagger.hilt.android.lifecycle.HiltViewModel
@ -48,6 +51,7 @@ class ForyouViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
channelRepository: ChannelRepository,
programmeRepository: ProgrammeRepository,
otherRepository: OtherRepository,
preferences: Preferences,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
workManager: WorkManager,
@ -95,14 +99,39 @@ class ForyouViewModel @Inject constructor(
initialValue = Duration.INFINITE
)
internal val recommend: StateFlow<Recommend> = unseensDuration
private val newRelease: StateFlow<Release?> = flow {
emit(otherRepository.getRelease())
}
.stateIn(
scope = viewModelScope,
initialValue = null,
started = SharingStarted.Lazily
)
internal val specs: StateFlow<List<Recommend.Spec>> = unseensDuration
.flatMapLatest { channelRepository.observeAllUnseenFavourites(it) }
.map { prev -> Recommend(prev.map { Recommend.UnseenSpec(it) }) }
.let { flow ->
combine(flow, newRelease) { channels, nr ->
buildList<Recommend.Spec> {
if (nr != null) {
val min = DataUnit.of(nr.assets.minOfOrNull { it.size }?.toLong() ?: 0L)
val max = DataUnit.of(nr.assets.maxOfOrNull { it.size }?.toLong() ?: 0L)
this += Recommend.NewRelease(
name = nr.name,
description = nr.body,
downloadCount = nr.assets.sumOf { it.downloadCount },
size = min..max,
url = nr.htmlUrl
)
}
this += channels.map { channel -> Recommend.UnseenSpec(channel) }
}
}
}
.flowOn(ioDispatcher)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = Recommend()
initialValue = emptyList()
)
internal fun onUnsubscribePlaylist(url: String) {

View File

@ -1,6 +1,7 @@
package com.m3u.feature.foryou.components.recommend
import androidx.compose.runtime.Immutable
import com.m3u.core.unit.DataUnit
import com.m3u.data.database.model.Playlist
import com.m3u.data.database.model.Channel
@ -25,4 +26,13 @@ internal class Recommend(
data class UnseenSpec(
val channel: Channel
) : Spec
@Immutable
data class NewRelease(
val name: String,
val description: String,
val downloadCount: Int,
val size: ClosedRange<DataUnit>,
val url: String,
): Spec
}

View File

@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import com.m3u.core.wrapper.eventOf
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
@ -29,45 +30,55 @@ import androidx.tv.material3.Carousel as TvCarousel
@Composable
internal fun RecommendGallery(
recommend: Recommend,
specs: List<Recommend.Spec>,
onClickChannel: (Channel) -> Unit,
navigateToPlaylist: (Playlist) -> Unit,
onSpecChanged: (Recommend.Spec) -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
val uriHandler = LocalUriHandler.current
val tv = isTelevision()
val onClick = { spec: Recommend.Spec ->
when (spec) {
is Recommend.UnseenSpec -> {
onClickChannel(spec.channel)
}
is Recommend.DiscoverSpec -> {
Events.discoverCategory = eventOf(spec.category)
navigateToPlaylist(spec.playlist)
}
is Recommend.NewRelease -> {
uriHandler.openUri(spec.url)
}
}
}
if (!tv) {
val state = rememberPagerState { recommend.size }
val state = rememberPagerState { specs.size }
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(spacing.medium)
) {
LaunchedEffect(state.currentPage) {
onSpecChanged(recommend[state.currentPage])
onSpecChanged(specs[state.currentPage])
}
HorizontalPager(
state = state,
contentPadding = PaddingValues(horizontal = spacing.medium),
modifier = Modifier.animateContentSize()
) { page ->
val spec = recommend[page]
val spec = specs[page]
val pageOffset =
((state.currentPage - page) + state.currentPageOffsetFraction).absoluteValue
RecommendItem(
spec = spec,
pageOffset = pageOffset,
onClick = {
when (spec) {
is Recommend.UnseenSpec -> onClickChannel(spec.channel)
is Recommend.DiscoverSpec -> {
Events.discoverCategory = eventOf(spec.category)
navigateToPlaylist(spec.playlist)
}
}
}
onClick = { onClick(spec) }
)
}
HorizontalPagerIndicator(
@ -79,7 +90,7 @@ internal fun RecommendGallery(
}
} else {
TvCarousel(
itemCount = recommend.size,
itemCount = specs.size,
contentTransformEndToStart =
fadeIn(tween(1000)) togetherWith fadeOut(tween(1000)),
contentTransformStartToEnd =
@ -88,19 +99,11 @@ internal fun RecommendGallery(
.padding(spacing.medium)
.then(modifier)
) { index ->
val spec = recommend[index]
val spec = specs[index]
RecommendItem(
spec = spec,
pageOffset = 0f,
onClick = {
when (spec) {
is Recommend.UnseenSpec -> onClickChannel(spec.channel)
is Recommend.DiscoverSpec -> {
Events.discoverCategory = eventOf(spec.category)
navigateToPlaylist(spec.playlist)
}
}
},
onClick = { onClick(spec) },
modifier = Modifier.animateEnterExit(
enter = slideInHorizontally(animationSpec = tween(1000)) { it / 2 },
exit = slideOutHorizontally(animationSpec = tween(1000))

View File

@ -1,12 +1,20 @@
package com.m3u.feature.foryou.components.recommend
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.NewReleases
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -16,12 +24,15 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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 androidx.compose.ui.util.lerp
import coil.compose.AsyncImage
import coil.request.ImageRequest
@ -31,6 +42,8 @@ import com.m3u.i18n.R.string
import com.m3u.material.brush.RecommendCardContainerBrush
import com.m3u.material.ktx.isTelevision
import com.m3u.material.model.LocalSpacing
import com.m3u.ui.FontFamilies
import com.m3u.ui.createPremiumBrush
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlin.time.Duration.Companion.days
@ -51,6 +64,7 @@ internal fun RecommendItem(
when (spec) {
is Recommend.UnseenSpec -> UnseenContent(spec)
is Recommend.DiscoverSpec -> DiscoverContent(spec)
is Recommend.NewRelease -> NewReleaseContent(spec)
}
}
}
@ -201,4 +215,78 @@ private fun DiscoverContent(spec: Recommend.DiscoverSpec) {
fontWeight = FontWeight.Black,
maxLines = 1
)
}
@Composable
private fun NewReleaseContent(spec: Recommend.NewRelease) {
val spacing = LocalSpacing.current
Row(
Modifier
.fillMaxWidth()
.background(
Brush.createPremiumBrush(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
)
)
.padding(spacing.medium)
) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimary) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(string.feat_foryou_new_release).title(),
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Icon(imageVector = Icons.Rounded.NewReleases, contentDescription = null)
}
Text(
text = spec.name,
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(0.56f),
)
Text(
text = spec.description,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamilies.LexendExa
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = spec.size.toString(),
style = MaterialTheme.typography.labelMedium,
color = LocalContentColor.current.copy(0.56f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = LocalContentColor.current.copy(0.56f)
)
Text(
text = spec.downloadCount.toString(),
style = MaterialTheme.typography.labelMedium,
color = LocalContentColor.current.copy(0.56f)
)
}
}
}
}
}
}

View File

@ -10,6 +10,7 @@
<string name="feat_foryou_recommend_unseen_more_than_days">more than %d days</string>
<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 television</string>
<string name="feat_foryou_connect_subtitle">Make sure to connect to the same Wi-Fi</string>
</resources>

View File

@ -0,0 +1,43 @@
package com.m3u.ui
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@Composable
fun Brush.Companion.createPremiumBrush(
color1: Color = MaterialTheme.colorScheme.primaryContainer,
color2: Color = MaterialTheme.colorScheme.secondaryContainer
): Brush {
val transition = rememberInfiniteTransition("premium-brush")
val leftColor by transition.animateColor(
initialValue = color1,
targetValue = color2,
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "left"
)
val rightColor by transition.animateColor(
initialValue = color2,
targetValue = color1,
animationSpec = infiniteRepeatable(
animation = tween(1600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "right"
)
return Brush.linearGradient(
colors = listOf(leftColor, rightColor)
)
}