diff --git a/core/src/main/java/com/m3u/core/architecture/logger/Profiles.kt b/core/src/main/java/com/m3u/core/architecture/logger/Profiles.kt index 11fc40bec..68a2aa227 100644 --- a/core/src/main/java/com/m3u/core/architecture/logger/Profiles.kt +++ b/core/src/main/java/com/m3u/core/architecture/logger/Profiles.kt @@ -8,6 +8,7 @@ object Profiles { val VIEWMODEL_PLAYLIST = Profile("viewmodel-playlist", Message.LEVEL_INFO) val VIEWMODEL_SETTING = Profile("viewmodel-setting") val VIEWMODEL_STREAM = Profile("viewmodel-stream") + val VIEWMODEL_PLAYLIST_CONFIGURATION = Profile("viewmodel-playlist-configuration") val REPOS_PLAYLIST = Profile("repos-playlist") val REPOS_STREAM = Profile("repos-stream") diff --git a/data/src/main/java/com/m3u/data/parser/ParserUtils.kt b/data/src/main/java/com/m3u/data/parser/ParserUtils.kt new file mode 100644 index 000000000..49266fb30 --- /dev/null +++ b/data/src/main/java/com/m3u/data/parser/ParserUtils.kt @@ -0,0 +1,59 @@ +package com.m3u.data.parser + +import com.m3u.core.architecture.logger.Logger +import com.m3u.core.architecture.logger.execute +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.decodeToSequence +import okhttp3.OkHttpClient +import okhttp3.Request + +class ParserUtils( + val json: Json, + val okHttpClient: OkHttpClient, + val logger: Logger, + val ioDispatcher: CoroutineDispatcher +) { + @OptIn(ExperimentalSerializationApi::class) + suspend inline fun newCall(url: String): T? = withContext(ioDispatcher) { + logger.execute { + okHttpClient.newCall( + Request.Builder().url(url).build() + ) + .execute() + .takeIf { it.isSuccessful } + ?.body + ?.byteStream() + ?.let { json.decodeFromStream(it) } + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend inline fun newCallOrThrow(url: String): T = + withContext(ioDispatcher) { + okHttpClient.newCall( + Request.Builder().url(url).build() + ) + .execute() + .takeIf { it.isSuccessful }!! + .body!! + .byteStream() + .let { json.decodeFromStream(it) } + } + + @OptIn(ExperimentalSerializationApi::class) + inline fun newSequenceCall(url: String): Sequence = + logger.execute { + okHttpClient.newCall( + Request.Builder().url(url).build() + ) + .execute() + .takeIf { it.isSuccessful } + ?.body + ?.byteStream() + ?.let { json.decodeToSequence(it) } + } ?: sequence { } +} \ No newline at end of file diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamInfo.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamInfo.kt index e1cf979ca..33e702d11 100644 --- a/data/src/main/java/com/m3u/data/parser/xtream/XtreamInfo.kt +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamInfo.kt @@ -6,20 +6,20 @@ import kotlinx.serialization.Serializable @Serializable data class XtreamInfo( @SerialName("server_info") - val serverInfo: ServerInfo, + val serverInfo: ServerInfo = ServerInfo(), @SerialName("user_info") - val userInfo: UserInfo + val userInfo: UserInfo = UserInfo() ) { @Serializable data class ServerInfo( @SerialName("https_port") - val httpsPort: String?, + val httpsPort: String? = null, @SerialName("port") - val port: String?, + val port: String? = null, // @SerialName("rtmp_port") // val rtmpPort: String?, @SerialName("server_protocol") - val serverProtocol: String?, + val serverProtocol: String? = null, // @SerialName("time_now") // val timeNow: String?, // @SerialName("timestamp_now") @@ -32,25 +32,25 @@ data class XtreamInfo( @Serializable data class UserInfo( -// @SerialName("active_cons") -// val activeCons: String?, + @SerialName("active_cons") + val activeCons: String? = null, @SerialName("allowed_output_formats") - val allowedOutputFormats: List, + val allowedOutputFormats: List = emptyList(), // @SerialName("auth") // val auth: Int?, -// @SerialName("created_at") -// val createdAt: String?, -// @SerialName("is_trial") -// val isTrial: String?, -// @SerialName("max_connections") -// val maxConnections: String?, + @SerialName("created_at") + val createdAt: String? = null, + @SerialName("is_trial") + val isTrial: String? = null, + @SerialName("max_connections") + val maxConnections: String? = null, // @SerialName("message") // val message: String?, // @SerialName("password") // val password: String?, -// @SerialName("status") -// val status: String?, -// @SerialName("username") -// val username: String? + @SerialName("status") + val status: String? = null, + @SerialName("username") + val username: String? = null ) } \ No newline at end of file diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt index bc600e7de..6fb68ebec 100644 --- a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParser.kt @@ -15,6 +15,8 @@ interface XtreamParser { fun parse(input: XtreamInput): Flow + suspend fun getInfo(input: XtreamInput): XtreamInfo + suspend fun getXtreamOutput(input: XtreamInput): XtreamOutput companion object { diff --git a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt index 1f709b6d2..689aacf33 100644 --- a/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt +++ b/data/src/main/java/com/m3u/data/parser/xtream/XtreamParserImpl.kt @@ -4,22 +4,18 @@ import com.m3u.core.architecture.dispatcher.Dispatcher import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO 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.data.api.OkhttpClient import com.m3u.data.database.model.DataSource +import com.m3u.data.parser.ParserUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.decodeToSequence import okhttp3.OkHttpClient -import okhttp3.Request import java.time.Duration import javax.inject.Inject @@ -44,6 +40,21 @@ internal class XtreamParserImpl @Inject constructor( .readTimeout(Duration.ofMillis(Int.MAX_VALUE.toLong())) .build() + private val utils by lazy { + ParserUtils( + json = json, + okHttpClient = okHttpClient, + logger = logger, + ioDispatcher = ioDispatcher + ) + } + + override suspend fun getInfo(input: XtreamInput): XtreamInfo { + val (basicUrl, username, password, _) = input + val infoUrl = XtreamParser.createInfoUrl(basicUrl, username, password) + return checkNotNull(utils.newCall(infoUrl)) + } + override fun parse(input: XtreamInput): Flow = channelFlow { val (basicUrl, username, password, type) = input val requiredLives = type == null || type == DataSource.Xtream.TYPE_LIVE @@ -68,17 +79,17 @@ internal class XtreamParserImpl @Inject constructor( XtreamParser.Action.GET_SERIES_STREAMS ) if (requiredLives) launch { - newSequenceCall(liveStreamsUrl) + utils.newSequenceCall(liveStreamsUrl) .asFlow() .collect { live -> send(live) } } if (requiredVods) launch { - newSequenceCall(vodStreamsUrl) + utils.newSequenceCall(vodStreamsUrl) .asFlow() .collect { vod -> send(vod) } } if (requiredSeries) launch { - newSequenceCall(seriesStreamsUrl) + utils.newSequenceCall(seriesStreamsUrl) .asFlow() .collect { serial -> send(serial) } } @@ -108,18 +119,18 @@ internal class XtreamParserImpl @Inject constructor( password, XtreamParser.Action.GET_SERIES_CATEGORIES ) - val info: XtreamInfo = newCall(infoUrl) ?: return XtreamOutput() + val info: XtreamInfo = utils.newCall(infoUrl) ?: return XtreamOutput() val allowedOutputFormats = info.userInfo.allowedOutputFormats val serverProtocol = info.serverInfo.serverProtocol ?: "http" val port = info.serverInfo.port?.toIntOrNull() val httpsPort = info.serverInfo.httpsPort?.toIntOrNull() val liveCategories: List = - if (requiredLives) newCall(liveCategoriesUrl) ?: emptyList() else emptyList() + if (requiredLives) utils.newCall(liveCategoriesUrl) ?: emptyList() else emptyList() val vodCategories: List = - if (requiredVods) newCall(vodCategoriesUrl) ?: emptyList() else emptyList() + if (requiredVods) utils.newCall(vodCategoriesUrl) ?: emptyList() else emptyList() val serialCategories: List = - if (requiredSeries) newCall(serialCategoriesUrl) ?: emptyList() else emptyList() + if (requiredSeries) utils.newCall(serialCategoriesUrl) ?: emptyList() else emptyList() return XtreamOutput( liveCategories = liveCategories, @@ -134,7 +145,7 @@ internal class XtreamParserImpl @Inject constructor( override suspend fun getSeriesInfoOrThrow(input: XtreamInput, seriesId: Int): XtreamStreamInfo { val (basicUrl, username, password, type) = input check(type == DataSource.Xtream.TYPE_SERIES) { "xtream input type must be `series`" } - return newCallOrThrow( + return utils.newCallOrThrow( XtreamParser.createActionUrl( basicUrl, username, @@ -144,44 +155,4 @@ internal class XtreamParserImpl @Inject constructor( ) ) } - - @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun newCall(url: String): T? = withContext(ioDispatcher) { - logger.execute { - okHttpClient.newCall( - Request.Builder().url(url).build() - ) - .execute() - .takeIf { it.isSuccessful } - ?.body - ?.byteStream() - ?.let { json.decodeFromStream(it) } - } - } - - @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun newCallOrThrow(url: String): T = - withContext(ioDispatcher) { - okHttpClient.newCall( - Request.Builder().url(url).build() - ) - .execute() - .takeIf { it.isSuccessful }!! - .body!! - .byteStream() - .let { json.decodeFromStream(it) } - } - - @OptIn(ExperimentalSerializationApi::class) - private inline fun newSequenceCall(url: String): Sequence = - logger.execute { - okHttpClient.newCall( - Request.Builder().url(url).build() - ) - .execute() - .takeIf { it.isSuccessful } - ?.body - ?.byteStream() - ?.let { json.decodeToSequence(it) } - } ?: sequence { } } \ No newline at end of file diff --git a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationScreen.kt b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationScreen.kt index 1396fcb5b..080b2204e 100644 --- a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationScreen.kt +++ b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationScreen.kt @@ -10,9 +10,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,16 +21,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Save import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -42,37 +32,33 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.rememberPermissionState import com.m3u.core.util.basic.title +import com.m3u.core.wrapper.Resource import com.m3u.data.database.model.DataSource import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.epgUrlsOrXtreamXmlUrl +import com.m3u.data.parser.xtream.XtreamInfo import com.m3u.data.repository.playlist.PlaylistRepository import com.m3u.features.playlist.configuration.components.AutoSyncProgrammesButton +import com.m3u.features.playlist.configuration.components.EpgManifestGallery import com.m3u.features.playlist.configuration.components.SyncProgrammesButton +import com.m3u.features.playlist.configuration.components.XtreamPanel import com.m3u.i18n.R.string import com.m3u.material.components.Background import com.m3u.material.components.Icon import com.m3u.material.components.PlaceholderField -import com.m3u.material.components.SelectionsDefaults import com.m3u.material.ktx.checkPermissionOrRationale import com.m3u.material.ktx.split -import com.m3u.material.model.LocalHazeState import com.m3u.material.model.LocalSpacing -import com.m3u.material.shape.AbsoluteSmoothCornerShape import com.m3u.ui.helper.LocalHelper import com.m3u.ui.helper.Metadata -import dev.chrisbanes.haze.HazeDefaults -import dev.chrisbanes.haze.haze import kotlinx.datetime.LocalDateTime @Composable @@ -90,6 +76,7 @@ internal fun PlaylistConfigurationRoute( val manifest by viewModel.manifest.collectAsStateWithLifecycle() val subscribingOrRefreshing by viewModel.subscribingOrRefreshing.collectAsStateWithLifecycle() val expired by viewModel.expired.collectAsStateWithLifecycle() + val xtreamUserInfo by viewModel.xtreamUserInfo.collectAsStateWithLifecycle() LifecycleResumeEffect(playlist?.title) { Metadata.title = AnnotatedString(playlist?.title?.title().orEmpty()) @@ -105,6 +92,7 @@ internal fun PlaylistConfigurationRoute( manifest = manifest, subscribingOrRefreshing = subscribingOrRefreshing, expired = expired, + xtreamUserInfo = xtreamUserInfo, onUpdatePlaylistTitle = viewModel::onUpdatePlaylistTitle, onUpdatePlaylistUserAgent = viewModel::onUpdatePlaylistUserAgent, onUpdateEpgPlaylist = viewModel::onUpdateEpgPlaylist, @@ -138,6 +126,7 @@ private fun PlaylistConfigurationScreen( manifest: EpgManifest, subscribingOrRefreshing: Boolean, expired: LocalDateTime?, + xtreamUserInfo: Resource, onUpdatePlaylistTitle: (String) -> Unit, onUpdatePlaylistUserAgent: (String?) -> Unit, onUpdateEpgPlaylist: (PlaylistRepository.UpdateEpgPlaylistUseCase) -> Unit, @@ -157,48 +146,62 @@ private fun PlaylistConfigurationScreen( Background(modifier) { Box { val (outer, inner) = contentPadding split WindowInsetsSides.Top - Column( + LazyColumn( verticalArrangement = Arrangement.spacedBy(spacing.small), modifier = Modifier .padding(outer) .padding(spacing.medium) ) { - PlaceholderField( - text = title, - placeholder = stringResource(string.feat_playlist_configuration_title).title(), - onValueChange = { title = it }, - ) - PlaceholderField( - text = userAgent, - placeholder = stringResource(string.feat_playlist_configuration_user_agent).title(), - onValueChange = { userAgent = it } - ) - AnimatedVisibility(playlist.epgUrlsOrXtreamXmlUrl().isNotEmpty()) { - Column( - verticalArrangement = Arrangement.spacedBy(spacing.small) - ) { - SyncProgrammesButton( - subscribingOrRefreshing = subscribingOrRefreshing, - expired = expired, - onSyncProgrammes = onSyncProgrammes - ) - AutoSyncProgrammesButton( - checked = playlist.autoRefreshProgrammes, - onCheckedChange = onUpdatePlaylistAutoRefreshProgrammes + item { + PlaceholderField( + text = title, + placeholder = stringResource(string.feat_playlist_configuration_title).title(), + onValueChange = { title = it }, + ) + } + item { + PlaceholderField( + text = userAgent, + placeholder = stringResource(string.feat_playlist_configuration_user_agent).title(), + onValueChange = { userAgent = it } + ) + } + item { + AnimatedVisibility(playlist.epgUrlsOrXtreamXmlUrl().isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing.small) + ) { + SyncProgrammesButton( + subscribingOrRefreshing = subscribingOrRefreshing, + expired = expired, + onSyncProgrammes = onSyncProgrammes + ) + AutoSyncProgrammesButton( + checked = playlist.autoRefreshProgrammes, + onCheckedChange = onUpdatePlaylistAutoRefreshProgrammes + ) + } + } + } + item { + if (playlist.source == DataSource.M3U) { + EpgManifestGallery( + playlistUrl = playlist.url, + manifest = manifest, + contentPadding = inner, + onUpdateEpgPlaylist = onUpdateEpgPlaylist, + modifier = Modifier.fillMaxWidth() ) } } - if (playlist.source == DataSource.M3U) { - EpgManifestGallery( - playlistUrl = playlist.url, - manifest = manifest, - contentPadding = inner, - onUpdateEpgPlaylist = onUpdateEpgPlaylist, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) + item { + if (playlist.source == DataSource.Xtream) { + XtreamPanel( + info = xtreamUserInfo, + modifier = Modifier.fillMaxWidth() + ) + } } } @@ -233,103 +236,3 @@ private fun PlaylistConfigurationScreen( } } } - -@Composable -private fun EpgManifestGallery( - playlistUrl: String, - manifest: EpgManifest, - contentPadding: PaddingValues, - onUpdateEpgPlaylist: (PlaylistRepository.UpdateEpgPlaylistUseCase) -> Unit, - modifier: Modifier = Modifier -) { - val spacing = LocalSpacing.current - Column( - verticalArrangement = Arrangement.spacedBy(spacing.medium), - modifier = modifier - ) { - Text( - text = stringResource(string.feat_playlist_configuration_enabled_epgs).title(), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .background(MaterialTheme.colorScheme.surface) - .fillMaxWidth() - .padding( - horizontal = spacing.medium, - vertical = spacing.small - ) - ) - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(spacing.medium), - modifier = Modifier - .haze( - LocalHazeState.current, - HazeDefaults.style(MaterialTheme.colorScheme.surface) - ) - .fillMaxWidth() - .weight(1f) - ) { - items(manifest.entries.toList()) { (epg, associated) -> - EpgManifestGalleryItem( - playlistUrl = playlistUrl, - epg = epg, - associated = associated, - onUpdateEpgPlaylist = onUpdateEpgPlaylist, - ) - } - } - } -} - -@Composable -private fun EpgManifestGalleryItem( - playlistUrl: String, - epg: Playlist, - associated: Boolean, - onUpdateEpgPlaylist: (PlaylistRepository.UpdateEpgPlaylistUseCase) -> Unit, - modifier: Modifier = Modifier -) { - val spacing = LocalSpacing.current - ListItem( - headlineContent = { - Text( - text = epg.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - supportingContent = { - Text( - text = epg.url - ) - }, - trailingContent = { - Switch( - checked = associated, - onCheckedChange = null - ) - }, - colors = ListItemDefaults.colors( - supportingColor = MaterialTheme - .colorScheme - .onSurfaceVariant.copy(0.38f) - ), - modifier = Modifier - .border( - 1.dp, - LocalContentColor.current.copy(0.38f), - SelectionsDefaults.Shape - ) - .clip(AbsoluteSmoothCornerShape(spacing.medium, 65)) - .clickable { - onUpdateEpgPlaylist( - PlaylistRepository.UpdateEpgPlaylistUseCase( - playlistUrl = playlistUrl, - epgUrl = epg.url, - action = !associated - ) - ) - } - .then(modifier) - ) -} \ No newline at end of file diff --git a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationViewModel.kt b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationViewModel.kt index 3e30a1254..601cbb0ec 100644 --- a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationViewModel.kt +++ b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/PlaylistConfigurationViewModel.kt @@ -8,7 +8,16 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import com.m3u.core.architecture.dispatcher.Dispatcher import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO +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.wrapper.Resource +import com.m3u.core.wrapper.asResource +import com.m3u.data.database.model.DataSource import com.m3u.data.database.model.Playlist +import com.m3u.data.parser.xtream.XtreamInfo +import com.m3u.data.parser.xtream.XtreamInput +import com.m3u.data.parser.xtream.XtreamParser import com.m3u.data.repository.playlist.PlaylistRepository import com.m3u.data.repository.programme.ProgrammeRepository import com.m3u.data.worker.SubscriptionWorker @@ -17,6 +26,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -34,10 +44,13 @@ internal typealias EpgManifest = Map class PlaylistConfigurationViewModel @Inject constructor( private val playlistRepository: PlaylistRepository, private val programmeRepository: ProgrammeRepository, + private val xtreamParser: XtreamParser, private val workManager: WorkManager, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, - savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle, + delegate: Logger ) : ViewModel() { + private val logger = delegate.install(Profiles.VIEWMODEL_PLAYLIST_CONFIGURATION) private val playlistUrl: StateFlow = savedStateHandle .getStateFlow(PlaylistConfigurationNavigation.TYPE_PLAYLIST_URL, "") internal val playlist: StateFlow = playlistUrl.flatMapLatest { @@ -49,6 +62,25 @@ class PlaylistConfigurationViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L) ) + internal val xtreamUserInfo: StateFlow> = + playlist.map { playlist -> + playlist ?: return@map null + if (playlist.source != DataSource.Xtream) return@map null + val xtreamInput = XtreamInput + .decodeFromPlaylistUrlOrNull(playlist.url) + ?: return@map null + xtreamParser + .getInfo(xtreamInput) + .userInfo + } + .filterNotNull() + .asResource() + .stateIn( + scope = viewModelScope, + initialValue = Resource.Loading, + started = SharingStarted.Lazily + ) + internal val manifest: StateFlow = combine( playlistRepository.observeAllEpgs(), playlist diff --git a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/EpgManifestGallery.kt b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/EpgManifestGallery.kt new file mode 100644 index 000000000..761b8c8f6 --- /dev/null +++ b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/EpgManifestGallery.kt @@ -0,0 +1,135 @@ +package com.m3u.features.playlist.configuration.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m3u.core.util.basic.title +import com.m3u.data.database.model.Playlist +import com.m3u.data.repository.playlist.PlaylistRepository +import com.m3u.features.playlist.configuration.EpgManifest +import com.m3u.i18n.R.string +import com.m3u.material.components.SelectionsDefaults +import com.m3u.material.model.LocalHazeState +import com.m3u.material.model.LocalSpacing +import com.m3u.material.shape.AbsoluteSmoothCornerShape +import dev.chrisbanes.haze.HazeDefaults +import dev.chrisbanes.haze.haze + +@Composable +internal fun EpgManifestGallery( + playlistUrl: String, + manifest: EpgManifest, + contentPadding: PaddingValues, + onUpdateEpgPlaylist: (PlaylistRepository.UpdateEpgPlaylistUseCase) -> Unit, + modifier: Modifier = Modifier +) { + val spacing = LocalSpacing.current + Column( + verticalArrangement = Arrangement.spacedBy(spacing.medium), + modifier = modifier + ) { + Text( + text = stringResource(string.feat_playlist_configuration_enabled_epgs).title(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + .padding( + horizontal = spacing.medium, + vertical = spacing.small + ) + ) + LazyColumn( + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(spacing.medium), + modifier = Modifier + .haze( + LocalHazeState.current, + HazeDefaults.style(MaterialTheme.colorScheme.surface) + ) + .fillMaxWidth() + .weight(1f) + ) { + items(manifest.entries.toList()) { (epg, associated) -> + EpgManifestGalleryItem( + playlistUrl = playlistUrl, + epg = epg, + associated = associated, + onUpdateEpgPlaylist = onUpdateEpgPlaylist, + ) + } + } + } +} + +@Composable +private fun EpgManifestGalleryItem( + playlistUrl: String, + epg: Playlist, + associated: Boolean, + onUpdateEpgPlaylist: (PlaylistRepository.UpdateEpgPlaylistUseCase) -> Unit, + modifier: Modifier = Modifier +) { + val spacing = LocalSpacing.current + ListItem( + headlineContent = { + Text( + text = epg.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = epg.url + ) + }, + trailingContent = { + Switch( + checked = associated, + onCheckedChange = null + ) + }, + colors = ListItemDefaults.colors( + supportingColor = MaterialTheme + .colorScheme + .onSurfaceVariant.copy(0.38f) + ), + modifier = Modifier + .border( + 1.dp, + LocalContentColor.current.copy(0.38f), + SelectionsDefaults.Shape + ) + .clip(AbsoluteSmoothCornerShape(spacing.medium, 65)) + .clickable { + onUpdateEpgPlaylist( + PlaylistRepository.UpdateEpgPlaylistUseCase( + playlistUrl = playlistUrl, + epgUrl = epg.url, + action = !associated + ) + ) + } + .then(modifier) + ) +} \ No newline at end of file diff --git a/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/XtreamPanel.kt b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/XtreamPanel.kt new file mode 100644 index 000000000..ccbce6adc --- /dev/null +++ b/features/playlist-configuration/src/main/java/com/m3u/features/playlist/configuration/components/XtreamPanel.kt @@ -0,0 +1,125 @@ +package com.m3u.features.playlist.configuration.components + +import androidx.compose.animation.animateColorAsState +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.m3u.core.wrapper.Resource +import com.m3u.data.parser.xtream.XtreamInfo +import com.m3u.material.model.LocalSpacing +import com.m3u.ui.Badge +import com.m3u.ui.FontFamilies +import com.m3u.ui.TextBadge +import kotlinx.datetime.Instant + +@Composable +internal fun XtreamPanel( + info: Resource, + modifier: Modifier = Modifier +) { + val spacing = LocalSpacing.current + val containerColor by animateColorAsState( + targetValue = when (info) { + Resource.Loading -> MaterialTheme.colorScheme.primaryContainer + is Resource.Success -> MaterialTheme.colorScheme.surfaceContainer + is Resource.Failure -> MaterialTheme.colorScheme.errorContainer + }, + label = "xtream-panel-container-color" + ) + val contentColor by animateColorAsState( + targetValue = when (info) { + Resource.Loading -> MaterialTheme.colorScheme.onPrimaryContainer + is Resource.Success -> MaterialTheme.colorScheme.onSurface + is Resource.Failure -> MaterialTheme.colorScheme.onErrorContainer + }, + label = "xtream-panel-content-color" + ) + ElevatedCard( + modifier = modifier, + colors = CardDefaults.elevatedCardColors(containerColor, contentColor) + ) { + Box(Modifier.padding(spacing.medium)) { + when (info) { + Resource.Loading -> { + CircularProgressIndicator() + } + + is Resource.Success -> { + val userInfo = info.data + Column( + verticalArrangement = Arrangement.spacedBy(spacing.extraSmall) + ) { + Text( + text = userInfo.username.orEmpty(), + style = MaterialTheme.typography.titleLarge + ) + Text( + text = Instant.fromEpochSeconds( + (userInfo.createdAt ?: "0").toLong() + ).toString(), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.54f) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(spacing.small), + verticalAlignment = Alignment.CenterVertically + ) { + TextBadge( + text = userInfo.status.orEmpty() + ) + if (userInfo.isTrial == "0") { + TextBadge( + text = "Trial", + color = MaterialTheme.colorScheme.tertiary + ) + } + Badge( + color = MaterialTheme.colorScheme.secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.extraSmall) + ) { + Icon( + imageVector = Icons.Rounded.Link, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text( + text = "${userInfo.activeCons.orEmpty()}/${userInfo.maxConnections.orEmpty()}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamilies.LexendExa + ) + } + } + } + } + } + + is Resource.Failure -> { + Text( + text = info.message.orEmpty(), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/m3u/ui/Badges.kt b/ui/src/main/java/com/m3u/ui/Badges.kt index 74fa5ebfc..d8cc7e0e1 100644 --- a/ui/src/main/java/com/m3u/ui/Badges.kt +++ b/ui/src/main/java/com/m3u/ui/Badges.kt @@ -75,9 +75,17 @@ fun Badge( @Composable fun TextBadge( text: String, + shape: Shape = AbsoluteSmoothCornerShape(LocalSpacing.current.small, 65), + color: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(color), modifier: Modifier = Modifier ) { - Badge(modifier) { + Badge( + shape = shape, + color = color, + contentColor = contentColor, + modifier = modifier + ) { Text( text = text, style = MaterialTheme.typography.bodyMedium.copy(