Initial upload: iptv-android-tv-2
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ma.tkgraph.xtreamtv"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ma.tkgraph.xtreamtv"
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "1.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
|
||||
implementation(libs.androidx.tv.material)
|
||||
implementation(libs.androidx.tv.foundation)
|
||||
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.exoplayer.dash)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.gson)
|
||||
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# MVP : pas de règles particulières.
|
||||
# Ajouter des règles si vous activez minifyEnabled=true et si votre API utilise de la réflexion JSON avancée.
|
||||
@@ -0,0 +1,34 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher_foreground"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.XtreamTv"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,929 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesEpisodeItem
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
|
||||
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
|
||||
import ma.tkgraph.xtreamtv.data.storage.ConfigStorage
|
||||
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.VodScreen
|
||||
import ma.tkgraph.xtreamtv.ui.theme.XtreamTvTheme
|
||||
import ma.tkgraph.xtreamtv.util.XtreamUrlBuilder
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
XtreamTvTheme {
|
||||
XtreamTvApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val FAVORITES_CATEGORY_ID = "__favorites__"
|
||||
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
|
||||
|
||||
private enum class MediaReturnTarget {
|
||||
HOME,
|
||||
VOD,
|
||||
SERIES
|
||||
}
|
||||
|
||||
private sealed interface AppScreen {
|
||||
data object Loading : AppScreen
|
||||
data object Login : AppScreen
|
||||
data object Home : AppScreen
|
||||
data object Vod : AppScreen
|
||||
data object Series : AppScreen
|
||||
data object ProgramGuide : AppScreen
|
||||
data class ReplayGuide(val stream: LiveStreamDto) : AppScreen
|
||||
data class Player(
|
||||
val stream: LiveStreamDto,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val isReplay: Boolean = false,
|
||||
val programTitle: String? = null,
|
||||
val programTime: String? = null
|
||||
) : AppScreen
|
||||
data class MediaPlayer(
|
||||
val title: String,
|
||||
val logoUrl: String?,
|
||||
val url: String,
|
||||
val returnTarget: MediaReturnTarget,
|
||||
val subtitle: String? = null,
|
||||
val favoriteId: String? = null
|
||||
) : AppScreen
|
||||
}
|
||||
|
||||
private data class XtreamContent(
|
||||
val streams: List<LiveStreamDto>,
|
||||
val categories: List<LiveCategoryDto>
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun XtreamTvApp() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val configStorage = remember { ConfigStorage(context) }
|
||||
|
||||
var screen by remember { mutableStateOf<AppScreen>(AppScreen.Loading) }
|
||||
var config by remember { mutableStateOf<XtreamConfig?>(null) }
|
||||
var liveStreams by remember { mutableStateOf<List<LiveStreamDto>>(emptyList()) }
|
||||
var liveCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
|
||||
var loginErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var selectedCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedStreamId by remember { mutableStateOf<String?>(null) }
|
||||
var categorySearchQuery by remember { mutableStateOf("") }
|
||||
var streamSearchQuery by remember { mutableStateOf("") }
|
||||
var favoriteStreamIds by remember { mutableStateOf(configStorage.loadFavoriteStreamIds()) }
|
||||
var pinnedStreamId by remember { mutableStateOf(configStorage.loadPinnedStreamId()) }
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
var focusSelectedStreamRequest by remember { mutableStateOf(0) }
|
||||
|
||||
var epgByStreamId by remember { mutableStateOf<Map<String, List<EpgProgramDto>>>(emptyMap()) }
|
||||
var loadingEpgStreamIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
var fullEpgByStreamId by remember { mutableStateOf<Map<String, List<EpgProgramDto>>>(emptyMap()) }
|
||||
var loadingFullEpgStreamIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var fullEpgErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
var vodMovies by remember { mutableStateOf<List<VodStreamDto>>(emptyList()) }
|
||||
var vodCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
|
||||
var isVodLoading by remember { mutableStateOf(false) }
|
||||
var vodErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var selectedVodCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedVodMovieId by remember { mutableStateOf<String?>(null) }
|
||||
var vodCategorySearchQuery by remember { mutableStateOf("") }
|
||||
var vodMovieSearchQuery by remember { mutableStateOf("") }
|
||||
var favoriteVodIds by remember { mutableStateOf(configStorage.loadFavoriteVodIds()) }
|
||||
|
||||
var seriesItems by remember { mutableStateOf<List<SeriesDto>>(emptyList()) }
|
||||
var seriesCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
|
||||
var isSeriesLoading by remember { mutableStateOf(false) }
|
||||
var seriesErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var selectedSeriesCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedSeriesId by remember { mutableStateOf<String?>(null) }
|
||||
var seriesCategorySearchQuery by remember { mutableStateOf("") }
|
||||
var seriesSearchQuery by remember { mutableStateOf("") }
|
||||
var episodeSearchQuery by remember { mutableStateOf("") }
|
||||
var favoriteSeriesIds by remember { mutableStateOf(configStorage.loadFavoriteSeriesIds()) }
|
||||
var seriesInfoById by remember { mutableStateOf<Map<String, SeriesInfoResponse>>(emptyMap()) }
|
||||
var loadingSeriesInfoIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var seriesInfoErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
fun repository(): XtreamRepository? {
|
||||
val currentConfig = config ?: return null
|
||||
return XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
|
||||
}
|
||||
|
||||
fun requestEpg(stream: LiveStreamDto) {
|
||||
val currentConfig = config ?: return
|
||||
val streamId = stream.streamId
|
||||
if (epgByStreamId.containsKey(streamId) || loadingEpgStreamIds.contains(streamId)) return
|
||||
|
||||
loadingEpgStreamIds = loadingEpgStreamIds + streamId
|
||||
scope.launch {
|
||||
val programs = runCatching {
|
||||
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadShortEpg(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password,
|
||||
streamId = streamId,
|
||||
limit = 8
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
epgByStreamId = epgByStreamId + (streamId to programs)
|
||||
loadingEpgStreamIds = loadingEpgStreamIds - streamId
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFullEpg(stream: LiveStreamDto, forceRefresh: Boolean = false) {
|
||||
val currentConfig = config ?: return
|
||||
val streamId = stream.streamId
|
||||
if (!forceRefresh && fullEpgByStreamId.containsKey(streamId)) return
|
||||
if (loadingFullEpgStreamIds.contains(streamId)) return
|
||||
|
||||
loadingFullEpgStreamIds = loadingFullEpgStreamIds + streamId
|
||||
fullEpgErrors = fullEpgErrors - streamId
|
||||
|
||||
scope.launch {
|
||||
runCatching {
|
||||
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadFullEpg(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password,
|
||||
streamId = streamId
|
||||
)
|
||||
}.onSuccess { programs ->
|
||||
fullEpgByStreamId = fullEpgByStreamId + (streamId to programs)
|
||||
epgByStreamId = epgByStreamId + (streamId to programs.take(8))
|
||||
fullEpgErrors = fullEpgErrors - streamId
|
||||
}.onFailure { throwable ->
|
||||
fullEpgErrors = fullEpgErrors + (streamId to (throwable.message ?: "Impossible de charger le guide replay complet."))
|
||||
}
|
||||
|
||||
loadingFullEpgStreamIds = loadingFullEpgStreamIds - streamId
|
||||
}
|
||||
}
|
||||
|
||||
fun requestVodContent(forceRefresh: Boolean = false) {
|
||||
val currentConfig = config ?: return
|
||||
if (!forceRefresh && vodMovies.isNotEmpty()) return
|
||||
if (isVodLoading) return
|
||||
|
||||
isVodLoading = true
|
||||
vodErrorMessage = null
|
||||
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val repo = XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
|
||||
val categories = runCatching {
|
||||
repo.loadVodCategories(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val movies = repo.loadVodStreams(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password
|
||||
)
|
||||
|
||||
categories to movies
|
||||
}.onSuccess { (categories, movies) ->
|
||||
vodCategories = categories
|
||||
vodMovies = movies
|
||||
vodErrorMessage = null
|
||||
}.onFailure { throwable ->
|
||||
vodErrorMessage = throwable.message ?: "Impossible de charger les VOD."
|
||||
}
|
||||
|
||||
isVodLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSeriesContent(forceRefresh: Boolean = false) {
|
||||
val currentConfig = config ?: return
|
||||
if (!forceRefresh && seriesItems.isNotEmpty()) return
|
||||
if (isSeriesLoading) return
|
||||
|
||||
isSeriesLoading = true
|
||||
seriesErrorMessage = null
|
||||
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val repo = XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
|
||||
val categories = runCatching {
|
||||
repo.loadSeriesCategories(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val items = repo.loadSeries(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password
|
||||
)
|
||||
|
||||
categories to items
|
||||
}.onSuccess { (categories, items) ->
|
||||
seriesCategories = categories
|
||||
seriesItems = items
|
||||
seriesErrorMessage = null
|
||||
}.onFailure { throwable ->
|
||||
seriesErrorMessage = throwable.message ?: "Impossible de charger les séries."
|
||||
}
|
||||
|
||||
isSeriesLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSeriesInfo(series: SeriesDto, forceRefresh: Boolean = false) {
|
||||
val currentConfig = config ?: return
|
||||
val seriesId = series.seriesId
|
||||
if (!forceRefresh && seriesInfoById.containsKey(seriesId)) return
|
||||
if (loadingSeriesInfoIds.contains(seriesId)) return
|
||||
|
||||
loadingSeriesInfoIds = loadingSeriesInfoIds + seriesId
|
||||
seriesInfoErrors = seriesInfoErrors - seriesId
|
||||
|
||||
scope.launch {
|
||||
runCatching {
|
||||
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadSeriesInfo(
|
||||
username = currentConfig.username,
|
||||
password = currentConfig.password,
|
||||
seriesId = seriesId
|
||||
)
|
||||
}.onSuccess { info ->
|
||||
seriesInfoById = seriesInfoById + (seriesId to info)
|
||||
seriesInfoErrors = seriesInfoErrors - seriesId
|
||||
}.onFailure { throwable ->
|
||||
seriesInfoErrors = seriesInfoErrors + (seriesId to (throwable.message ?: "Impossible de charger les épisodes."))
|
||||
}
|
||||
|
||||
loadingSeriesInfoIds = loadingSeriesInfoIds - seriesId
|
||||
}
|
||||
}
|
||||
|
||||
fun openPlayer(stream: LiveStreamDto) {
|
||||
val currentConfig = config ?: return
|
||||
selectedStreamId = stream.streamId
|
||||
requestEpg(stream)
|
||||
screen = AppScreen.Player(
|
||||
stream = stream,
|
||||
title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
|
||||
url = XtreamUrlBuilder.liveUrl(
|
||||
config = currentConfig,
|
||||
streamId = stream.streamId,
|
||||
extension = "m3u8"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun openVodPlayer(movie: VodStreamDto) {
|
||||
val currentConfig = config ?: return
|
||||
selectedVodMovieId = movie.streamId
|
||||
screen = AppScreen.MediaPlayer(
|
||||
title = movie.titleText(),
|
||||
logoUrl = movie.streamIcon,
|
||||
url = movie.directSource?.takeIf { it.isNotBlank() }
|
||||
?: XtreamUrlBuilder.vodUrl(
|
||||
config = currentConfig,
|
||||
streamId = movie.streamId,
|
||||
extension = movie.extensionText()
|
||||
),
|
||||
subtitle = "VOD • ${movie.extensionText().uppercase()}",
|
||||
returnTarget = MediaReturnTarget.VOD,
|
||||
favoriteId = movie.streamId
|
||||
)
|
||||
}
|
||||
|
||||
fun openSeriesEpisodePlayer(series: SeriesDto, item: SeriesEpisodeItem) {
|
||||
val currentConfig = config ?: return
|
||||
val episode = item.episode
|
||||
val episodeId = episode.id?.takeIf { it.isNotBlank() } ?: return
|
||||
selectedSeriesId = series.seriesId
|
||||
screen = AppScreen.MediaPlayer(
|
||||
title = "${series.titleText()} • ${item.seasonText()} • ${episode.titleText()}",
|
||||
logoUrl = episode.info?.movieImage ?: series.cover,
|
||||
url = XtreamUrlBuilder.seriesUrl(
|
||||
config = currentConfig,
|
||||
episodeId = episodeId,
|
||||
extension = episode.extensionText()
|
||||
),
|
||||
subtitle = "${item.seasonText()} • Épisode ${episode.episodeNum ?: ""}".trim(),
|
||||
returnTarget = MediaReturnTarget.SERIES,
|
||||
favoriteId = series.seriesId
|
||||
)
|
||||
}
|
||||
|
||||
fun openReplayGuide(stream: LiveStreamDto) {
|
||||
selectedStreamId = stream.streamId
|
||||
requestFullEpg(stream)
|
||||
screen = AppScreen.ReplayGuide(stream = stream)
|
||||
}
|
||||
|
||||
fun openReplay(stream: LiveStreamDto, program: EpgProgramDto) {
|
||||
val currentConfig = config ?: return
|
||||
selectedStreamId = stream.streamId
|
||||
val title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" }
|
||||
screen = AppScreen.Player(
|
||||
stream = stream,
|
||||
title = title,
|
||||
url = XtreamUrlBuilder.timeshiftUrl(
|
||||
config = currentConfig,
|
||||
streamId = stream.streamId,
|
||||
start = program.start.orEmpty(),
|
||||
durationMinutes = program.durationMinutes(),
|
||||
extension = "ts"
|
||||
),
|
||||
isReplay = true,
|
||||
programTitle = program.titleText(),
|
||||
programTime = program.fullDateTimeText()
|
||||
)
|
||||
}
|
||||
|
||||
fun streamsForCurrentNavigation(): List<LiveStreamDto> {
|
||||
return filterStreamsForNavigation(
|
||||
streams = liveStreams,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
streamSearchQuery = streamSearchQuery,
|
||||
favoriteStreamIds = favoriteStreamIds
|
||||
)
|
||||
}
|
||||
|
||||
fun zap(offset: Int) {
|
||||
val currentConfig = config ?: return
|
||||
val candidates = streamsForCurrentNavigation().ifEmpty { liveStreams }
|
||||
if (candidates.isEmpty()) return
|
||||
|
||||
val currentId = selectedStreamId
|
||||
val currentIndex = candidates.indexOfFirst { it.streamId == currentId }
|
||||
val nextIndex = if (currentIndex < 0) {
|
||||
0
|
||||
} else {
|
||||
(currentIndex + offset + candidates.size) % candidates.size
|
||||
}
|
||||
|
||||
val nextStream = candidates[nextIndex]
|
||||
selectedStreamId = nextStream.streamId
|
||||
requestEpg(nextStream)
|
||||
screen = AppScreen.Player(
|
||||
stream = nextStream,
|
||||
title = nextStream.name.orEmpty().ifBlank { "Chaîne ${nextStream.streamId}" },
|
||||
url = XtreamUrlBuilder.liveUrl(
|
||||
config = currentConfig,
|
||||
streamId = nextStream.streamId,
|
||||
extension = "m3u8"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCatalogStates() {
|
||||
vodMovies = emptyList()
|
||||
vodCategories = emptyList()
|
||||
vodErrorMessage = null
|
||||
isVodLoading = false
|
||||
selectedVodCategoryId = null
|
||||
selectedVodMovieId = null
|
||||
vodCategorySearchQuery = ""
|
||||
vodMovieSearchQuery = ""
|
||||
seriesItems = emptyList()
|
||||
seriesCategories = emptyList()
|
||||
seriesErrorMessage = null
|
||||
isSeriesLoading = false
|
||||
selectedSeriesCategoryId = null
|
||||
selectedSeriesId = null
|
||||
seriesCategorySearchQuery = ""
|
||||
seriesSearchQuery = ""
|
||||
episodeSearchQuery = ""
|
||||
seriesInfoById = emptyMap()
|
||||
loadingSeriesInfoIds = emptySet()
|
||||
seriesInfoErrors = emptyMap()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedConfig = configStorage.load()
|
||||
|
||||
if (savedConfig == null) {
|
||||
screen = AppScreen.Login
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val content = loadXtreamContentFromServer(savedConfig)
|
||||
config = savedConfig
|
||||
liveStreams = content.streams
|
||||
liveCategories = content.categories
|
||||
favoriteStreamIds = configStorage.loadFavoriteStreamIds()
|
||||
favoriteVodIds = configStorage.loadFavoriteVodIds()
|
||||
favoriteSeriesIds = configStorage.loadFavoriteSeriesIds()
|
||||
pinnedStreamId = configStorage.loadPinnedStreamId()
|
||||
loginErrorMessage = null
|
||||
|
||||
val pinnedStream = pinnedStreamId?.let { id ->
|
||||
content.streams.firstOrNull { it.streamId == id }
|
||||
}
|
||||
|
||||
if (pinnedStream != null) {
|
||||
selectedCategoryId = pinnedStream.categoryId?.takeIf { it.isNotBlank() }
|
||||
selectedStreamId = pinnedStream.streamId
|
||||
requestEpg(pinnedStream)
|
||||
screen = AppScreen.Player(
|
||||
stream = pinnedStream,
|
||||
title = pinnedStream.name.orEmpty().ifBlank { "Chaîne ${pinnedStream.streamId}" },
|
||||
url = XtreamUrlBuilder.liveUrl(
|
||||
config = savedConfig,
|
||||
streamId = pinnedStream.streamId,
|
||||
extension = "m3u8"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
screen = AppScreen.Home
|
||||
}
|
||||
}.onFailure {
|
||||
configStorage.clear()
|
||||
config = null
|
||||
liveStreams = emptyList()
|
||||
liveCategories = emptyList()
|
||||
epgByStreamId = emptyMap()
|
||||
loadingEpgStreamIds = emptySet()
|
||||
fullEpgByStreamId = emptyMap()
|
||||
loadingFullEpgStreamIds = emptySet()
|
||||
fullEpgErrors = emptyMap()
|
||||
clearCatalogStates()
|
||||
loginErrorMessage = "Connexion automatique impossible. Vérifie tes identifiants."
|
||||
screen = AppScreen.Login
|
||||
}
|
||||
}
|
||||
|
||||
when (val currentScreen = screen) {
|
||||
AppScreen.Loading -> LoadingScreen(
|
||||
message = "Connexion automatique en cours..."
|
||||
)
|
||||
|
||||
AppScreen.Login -> LoginScreen(
|
||||
initialConfig = configStorage.load(),
|
||||
initialErrorMessage = loginErrorMessage,
|
||||
onLoginSuccess = { newConfig, streams, categories ->
|
||||
configStorage.save(newConfig)
|
||||
config = newConfig
|
||||
liveStreams = streams
|
||||
liveCategories = categories
|
||||
favoriteStreamIds = configStorage.loadFavoriteStreamIds()
|
||||
favoriteVodIds = configStorage.loadFavoriteVodIds()
|
||||
favoriteSeriesIds = configStorage.loadFavoriteSeriesIds()
|
||||
pinnedStreamId = configStorage.loadPinnedStreamId()
|
||||
epgByStreamId = emptyMap()
|
||||
loadingEpgStreamIds = emptySet()
|
||||
fullEpgByStreamId = emptyMap()
|
||||
loadingFullEpgStreamIds = emptySet()
|
||||
fullEpgErrors = emptyMap()
|
||||
clearCatalogStates()
|
||||
loginErrorMessage = null
|
||||
selectedCategoryId = null
|
||||
selectedStreamId = null
|
||||
screen = AppScreen.Home
|
||||
}
|
||||
)
|
||||
|
||||
AppScreen.Home -> {
|
||||
BackHandler { showExitDialog = true }
|
||||
|
||||
HomeScreen(
|
||||
streams = liveStreams,
|
||||
categories = liveCategories,
|
||||
epgByStreamId = epgByStreamId,
|
||||
loadingEpgStreamIds = loadingEpgStreamIds,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
selectedStreamId = selectedStreamId,
|
||||
favoriteStreamIds = favoriteStreamIds,
|
||||
pinnedStreamId = pinnedStreamId,
|
||||
categorySearchQuery = categorySearchQuery,
|
||||
streamSearchQuery = streamSearchQuery,
|
||||
focusSelectedStreamRequest = focusSelectedStreamRequest,
|
||||
onCategorySearchChanged = { categorySearchQuery = it },
|
||||
onStreamSearchChanged = { streamSearchQuery = it },
|
||||
onCategorySelected = { categoryId -> selectedCategoryId = categoryId },
|
||||
onStreamSelected = { stream -> openPlayer(stream) },
|
||||
onRequestEpg = { stream -> requestEpg(stream) },
|
||||
onOpenReplayGuide = { stream -> openReplayGuide(stream) },
|
||||
onOpenProgramGuide = { screen = AppScreen.ProgramGuide },
|
||||
onOpenVod = {
|
||||
screen = AppScreen.Vod
|
||||
requestVodContent()
|
||||
},
|
||||
onOpenSeries = {
|
||||
screen = AppScreen.Series
|
||||
requestSeriesContent()
|
||||
},
|
||||
onToggleFavorite = { stream ->
|
||||
favoriteStreamIds = configStorage.toggleFavorite(stream.streamId)
|
||||
},
|
||||
onPinStream = { stream ->
|
||||
if (pinnedStreamId == stream.streamId) {
|
||||
configStorage.clearPinnedStreamId()
|
||||
pinnedStreamId = null
|
||||
} else {
|
||||
configStorage.savePinnedStreamId(stream.streamId)
|
||||
pinnedStreamId = stream.streamId
|
||||
}
|
||||
},
|
||||
onClearPinned = {
|
||||
configStorage.clearPinnedStreamId()
|
||||
pinnedStreamId = null
|
||||
},
|
||||
onLogout = {
|
||||
configStorage.clear()
|
||||
config = null
|
||||
liveStreams = emptyList()
|
||||
liveCategories = emptyList()
|
||||
epgByStreamId = emptyMap()
|
||||
loadingEpgStreamIds = emptySet()
|
||||
fullEpgByStreamId = emptyMap()
|
||||
loadingFullEpgStreamIds = emptySet()
|
||||
fullEpgErrors = emptyMap()
|
||||
selectedCategoryId = null
|
||||
selectedStreamId = null
|
||||
categorySearchQuery = ""
|
||||
streamSearchQuery = ""
|
||||
clearCatalogStates()
|
||||
loginErrorMessage = null
|
||||
screen = AppScreen.Login
|
||||
}
|
||||
)
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitConfirmationDialog(
|
||||
onDismiss = { showExitDialog = false },
|
||||
onConfirm = {
|
||||
showExitDialog = false
|
||||
(context as? Activity)?.finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AppScreen.Vod -> {
|
||||
VodScreen(
|
||||
movies = vodMovies,
|
||||
categories = vodCategories,
|
||||
isLoading = isVodLoading,
|
||||
errorMessage = vodErrorMessage,
|
||||
selectedCategoryId = selectedVodCategoryId,
|
||||
selectedMovieId = selectedVodMovieId,
|
||||
favoriteMovieIds = favoriteVodIds,
|
||||
categorySearchQuery = vodCategorySearchQuery,
|
||||
movieSearchQuery = vodMovieSearchQuery,
|
||||
onCategorySelected = { selectedVodCategoryId = it },
|
||||
onCategorySearchChanged = { vodCategorySearchQuery = it },
|
||||
onMovieSearchChanged = { vodMovieSearchQuery = it },
|
||||
onMovieFocused = { movie -> selectedVodMovieId = movie.streamId },
|
||||
onMovieSelected = { movie -> openVodPlayer(movie) },
|
||||
onToggleFavorite = { movie ->
|
||||
favoriteVodIds = configStorage.toggleFavoriteVod(movie.streamId)
|
||||
},
|
||||
onReload = { requestVodContent(forceRefresh = true) },
|
||||
onBack = {
|
||||
focusSelectedStreamRequest += 1
|
||||
screen = AppScreen.Home
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AppScreen.Series -> {
|
||||
SeriesScreen(
|
||||
series = seriesItems,
|
||||
categories = seriesCategories,
|
||||
isLoading = isSeriesLoading,
|
||||
errorMessage = seriesErrorMessage,
|
||||
selectedCategoryId = selectedSeriesCategoryId,
|
||||
selectedSeriesId = selectedSeriesId,
|
||||
favoriteSeriesIds = favoriteSeriesIds,
|
||||
categorySearchQuery = seriesCategorySearchQuery,
|
||||
seriesSearchQuery = seriesSearchQuery,
|
||||
episodeSearchQuery = episodeSearchQuery,
|
||||
seriesInfoById = seriesInfoById,
|
||||
loadingSeriesInfoIds = loadingSeriesInfoIds,
|
||||
seriesInfoErrors = seriesInfoErrors,
|
||||
onCategorySelected = { selectedSeriesCategoryId = it },
|
||||
onCategorySearchChanged = { seriesCategorySearchQuery = it },
|
||||
onSeriesSearchChanged = { seriesSearchQuery = it },
|
||||
onEpisodeSearchChanged = { episodeSearchQuery = it },
|
||||
onSeriesFocused = { item ->
|
||||
selectedSeriesId = item.seriesId
|
||||
requestSeriesInfo(item)
|
||||
},
|
||||
onSeriesSelected = { item ->
|
||||
selectedSeriesId = item.seriesId
|
||||
requestSeriesInfo(item)
|
||||
},
|
||||
onToggleFavorite = { item ->
|
||||
favoriteSeriesIds = configStorage.toggleFavoriteSeries(item.seriesId)
|
||||
},
|
||||
onPlayEpisode = { item, episode -> openSeriesEpisodePlayer(item, episode) },
|
||||
onReload = { requestSeriesContent(forceRefresh = true) },
|
||||
onRetryInfo = { item -> requestSeriesInfo(item, forceRefresh = true) },
|
||||
onBack = {
|
||||
focusSelectedStreamRequest += 1
|
||||
screen = AppScreen.Home
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AppScreen.ProgramGuide -> {
|
||||
ProgramGuideScreen(
|
||||
streams = liveStreams,
|
||||
categories = liveCategories,
|
||||
favoriteStreamIds = favoriteStreamIds,
|
||||
selectedStreamId = selectedStreamId,
|
||||
epgByStreamId = epgByStreamId,
|
||||
loadingEpgStreamIds = loadingEpgStreamIds,
|
||||
fullEpgByStreamId = fullEpgByStreamId,
|
||||
loadingFullEpgStreamIds = loadingFullEpgStreamIds,
|
||||
fullEpgErrors = fullEpgErrors,
|
||||
onBack = {
|
||||
focusSelectedStreamRequest += 1
|
||||
screen = AppScreen.Home
|
||||
},
|
||||
onStreamFocused = { stream ->
|
||||
selectedStreamId = stream.streamId
|
||||
requestEpg(stream)
|
||||
requestFullEpg(stream)
|
||||
},
|
||||
onOpenLive = { stream -> openPlayer(stream) },
|
||||
onRetryFullGuide = { stream -> requestFullEpg(stream, forceRefresh = true) },
|
||||
onProgramSelected = { stream, program -> openReplay(stream, program) }
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.ReplayGuide -> {
|
||||
LaunchedEffect(currentScreen.stream.streamId) {
|
||||
requestFullEpg(currentScreen.stream)
|
||||
}
|
||||
|
||||
ReplayGuideScreen(
|
||||
stream = currentScreen.stream,
|
||||
programs = fullEpgByStreamId[currentScreen.stream.streamId].orEmpty(),
|
||||
isLoading = loadingFullEpgStreamIds.contains(currentScreen.stream.streamId),
|
||||
errorMessage = fullEpgErrors[currentScreen.stream.streamId],
|
||||
onBack = {
|
||||
focusSelectedStreamRequest += 1
|
||||
screen = AppScreen.Home
|
||||
},
|
||||
onRetry = { requestFullEpg(currentScreen.stream, forceRefresh = true) },
|
||||
onProgramSelected = { program -> openReplay(currentScreen.stream, program) }
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.Player -> {
|
||||
LaunchedEffect(currentScreen.stream.streamId) {
|
||||
requestEpg(currentScreen.stream)
|
||||
}
|
||||
|
||||
val currentLiveProgram = epgByStreamId[currentScreen.stream.streamId]
|
||||
.orEmpty()
|
||||
.firstOrNull { it.isNowPlaying() }
|
||||
?: epgByStreamId[currentScreen.stream.streamId].orEmpty().firstOrNull()
|
||||
|
||||
PlayerScreen(
|
||||
title = currentScreen.title,
|
||||
logoUrl = currentScreen.stream.streamIcon,
|
||||
streamUrl = currentScreen.url,
|
||||
isFavorite = favoriteStreamIds.contains(currentScreen.stream.streamId),
|
||||
isPinned = pinnedStreamId == currentScreen.stream.streamId,
|
||||
isReplay = currentScreen.isReplay,
|
||||
programTitle = currentScreen.programTitle ?: currentLiveProgram?.titleText(),
|
||||
programTime = currentScreen.programTime ?: currentLiveProgram?.timeRangeText(),
|
||||
enableZapping = true,
|
||||
onBack = {
|
||||
focusSelectedStreamRequest += 1
|
||||
screen = AppScreen.Home
|
||||
},
|
||||
onPrevious = { zap(offset = -1) },
|
||||
onNext = { zap(offset = 1) },
|
||||
onToggleFavorite = {
|
||||
favoriteStreamIds = configStorage.toggleFavorite(currentScreen.stream.streamId)
|
||||
},
|
||||
onTogglePinned = {
|
||||
if (pinnedStreamId == currentScreen.stream.streamId) {
|
||||
configStorage.clearPinnedStreamId()
|
||||
pinnedStreamId = null
|
||||
} else {
|
||||
configStorage.savePinnedStreamId(currentScreen.stream.streamId)
|
||||
pinnedStreamId = currentScreen.stream.streamId
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.MediaPlayer -> {
|
||||
val isMediaFavorite = when (currentScreen.returnTarget) {
|
||||
MediaReturnTarget.VOD -> currentScreen.favoriteId?.let { favoriteVodIds.contains(it) } == true
|
||||
MediaReturnTarget.SERIES -> currentScreen.favoriteId?.let { favoriteSeriesIds.contains(it) } == true
|
||||
MediaReturnTarget.HOME -> false
|
||||
}
|
||||
|
||||
PlayerScreen(
|
||||
title = currentScreen.title,
|
||||
logoUrl = currentScreen.logoUrl,
|
||||
streamUrl = currentScreen.url,
|
||||
isFavorite = isMediaFavorite,
|
||||
isPinned = false,
|
||||
isReplay = false,
|
||||
programTitle = currentScreen.subtitle,
|
||||
programTime = null,
|
||||
enableZapping = false,
|
||||
onBack = {
|
||||
screen = when (currentScreen.returnTarget) {
|
||||
MediaReturnTarget.HOME -> AppScreen.Home
|
||||
MediaReturnTarget.VOD -> AppScreen.Vod
|
||||
MediaReturnTarget.SERIES -> AppScreen.Series
|
||||
}
|
||||
},
|
||||
onPrevious = {},
|
||||
onNext = {},
|
||||
onToggleFavorite = {
|
||||
val favoriteId = currentScreen.favoriteId ?: return@PlayerScreen
|
||||
when (currentScreen.returnTarget) {
|
||||
MediaReturnTarget.VOD -> favoriteVodIds = configStorage.toggleFavoriteVod(favoriteId)
|
||||
MediaReturnTarget.SERIES -> favoriteSeriesIds = configStorage.toggleFavoriteSeries(favoriteId)
|
||||
MediaReturnTarget.HOME -> Unit
|
||||
}
|
||||
},
|
||||
onTogglePinned = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExitConfirmationDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = "Quitter l’application ?") },
|
||||
text = { Text(text = "Confirme si tu veux fermer Xtream TV Player.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(text = "Quitter") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(text = "Annuler") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingScreen(
|
||||
message: String
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF020B22))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.imedia_tv_background),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0x66000B1F))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 58.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadXtreamContentFromServer(
|
||||
config: XtreamConfig
|
||||
): XtreamContent {
|
||||
val repository = XtreamRepository(
|
||||
XtreamApiFactory.create(config.serverUrl)
|
||||
)
|
||||
|
||||
val account = repository.checkAccount(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
|
||||
val isAuthorized = account.userInfo?.auth == "1"
|
||||
val isActive = account.userInfo?.status.equals(
|
||||
other = "Active",
|
||||
ignoreCase = true
|
||||
)
|
||||
|
||||
if (!isAuthorized || !isActive) {
|
||||
error("Compte non autorisé ou abonnement inactif.")
|
||||
}
|
||||
|
||||
val categories = runCatching {
|
||||
repository.loadLiveCategories(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val streams = repository.loadLiveStreams(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
|
||||
return XtreamContent(
|
||||
streams = streams,
|
||||
categories = categories
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterStreamsForNavigation(
|
||||
streams: List<LiveStreamDto>,
|
||||
selectedCategoryId: String?,
|
||||
streamSearchQuery: String,
|
||||
favoriteStreamIds: Set<String>
|
||||
): List<LiveStreamDto> {
|
||||
val query = streamSearchQuery.trim()
|
||||
|
||||
return streams
|
||||
.asSequence()
|
||||
.filter { stream ->
|
||||
when (selectedCategoryId) {
|
||||
null -> true
|
||||
FAVORITES_CATEGORY_ID -> favoriteStreamIds.contains(stream.streamId)
|
||||
UNCATEGORIZED_CATEGORY_ID -> stream.categoryId.isNullOrBlank()
|
||||
else -> stream.categoryId == selectedCategoryId
|
||||
}
|
||||
}
|
||||
.filter { stream ->
|
||||
query.isBlank() ||
|
||||
stream.name.orEmpty().contains(query, ignoreCase = true) ||
|
||||
stream.streamId.contains(query, ignoreCase = true) ||
|
||||
stream.categoryId.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class EpgFullResponse(
|
||||
@SerializedName("epg_listings")
|
||||
val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import android.util.Base64
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val MAX_REPLAY_DURATION_MINUTES = 720
|
||||
|
||||
data class EpgProgramDto(
|
||||
@SerializedName("id")
|
||||
val id: String? = null,
|
||||
|
||||
@SerializedName("epg_id")
|
||||
val epgId: String? = null,
|
||||
|
||||
@SerializedName("title")
|
||||
val rawTitle: String? = null,
|
||||
|
||||
@SerializedName("description")
|
||||
val rawDescription: String? = null,
|
||||
|
||||
@SerializedName("lang")
|
||||
val lang: String? = null,
|
||||
|
||||
@SerializedName("start")
|
||||
val start: String? = null,
|
||||
|
||||
@SerializedName("end")
|
||||
val end: String? = null,
|
||||
|
||||
@SerializedName("channel_id")
|
||||
val channelId: String? = null,
|
||||
|
||||
@SerializedName("start_timestamp")
|
||||
val startTimestamp: String? = null,
|
||||
|
||||
@SerializedName("stop_timestamp")
|
||||
val stopTimestamp: String? = null,
|
||||
|
||||
@SerializedName("now_playing")
|
||||
val nowPlaying: Int? = null,
|
||||
|
||||
@SerializedName("has_archive")
|
||||
val hasArchive: Int? = null
|
||||
) {
|
||||
fun titleText(): String {
|
||||
return decodeMaybeBase64(rawTitle).ifBlank { "Programme sans titre" }
|
||||
}
|
||||
|
||||
fun descriptionText(): String {
|
||||
return decodeMaybeBase64(rawDescription)
|
||||
}
|
||||
|
||||
fun timeRangeText(): String {
|
||||
val startLabel = start.orEmpty().takeLastTime()
|
||||
val endLabel = end.orEmpty().takeLastTime()
|
||||
return when {
|
||||
startLabel.isNotBlank() && endLabel.isNotBlank() -> "$startLabel - $endLabel"
|
||||
startLabel.isNotBlank() -> startLabel
|
||||
else -> "Horaire inconnu"
|
||||
}
|
||||
}
|
||||
|
||||
fun fullDateTimeText(): String {
|
||||
val dateLabel = startMillis()?.let { millis ->
|
||||
SimpleDateFormat("EEE dd/MM", Locale.getDefault()).format(Date(millis))
|
||||
}.orEmpty()
|
||||
|
||||
return listOf(dateLabel, timeRangeText())
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • ")
|
||||
.ifBlank { "Horaire inconnu" }
|
||||
}
|
||||
|
||||
fun dayHeaderText(): String {
|
||||
return startMillis()?.let { millis ->
|
||||
SimpleDateFormat("EEEE dd/MM", Locale.getDefault()).format(Date(millis))
|
||||
} ?: "Jour inconnu"
|
||||
}
|
||||
|
||||
fun isNowPlaying(): Boolean {
|
||||
return nowPlaying == 1
|
||||
}
|
||||
|
||||
fun isReplayAvailable(): Boolean {
|
||||
return hasArchive == 1 && !start.isNullOrBlank()
|
||||
}
|
||||
|
||||
fun canPlayAsReplay(
|
||||
streamArchiveEnabled: Boolean,
|
||||
nowMillis: Long = System.currentTimeMillis()
|
||||
): Boolean {
|
||||
val startMillis = startMillis() ?: return false
|
||||
val archiveAvailable = streamArchiveEnabled || hasArchive == 1
|
||||
return archiveAvailable && startMillis <= nowMillis && !start.isNullOrBlank()
|
||||
}
|
||||
|
||||
fun startMillis(): Long? {
|
||||
startTimestamp?.toLongOrNull()?.let { seconds ->
|
||||
return seconds * 1000L
|
||||
}
|
||||
|
||||
val startText = start ?: return null
|
||||
return parseDateTime(startText)?.time
|
||||
}
|
||||
|
||||
fun stopMillis(): Long? {
|
||||
stopTimestamp?.toLongOrNull()?.let { seconds ->
|
||||
return seconds * 1000L
|
||||
}
|
||||
|
||||
val stopText = end ?: return null
|
||||
return parseDateTime(stopText)?.time
|
||||
}
|
||||
|
||||
fun durationMinutes(): Int {
|
||||
val timestampDuration = durationFromTimestamps()
|
||||
if (timestampDuration > 0) return timestampDuration
|
||||
|
||||
val parsedDuration = durationFromDates()
|
||||
if (parsedDuration > 0) return parsedDuration
|
||||
|
||||
return 60
|
||||
}
|
||||
|
||||
private fun durationFromTimestamps(): Int {
|
||||
val startSeconds = startTimestamp?.toLongOrNull() ?: return 0
|
||||
val stopSeconds = stopTimestamp?.toLongOrNull() ?: return 0
|
||||
val minutes = ((stopSeconds - startSeconds) / 60).toInt()
|
||||
return minutes.clampDuration()
|
||||
}
|
||||
|
||||
private fun durationFromDates(): Int {
|
||||
val startMillis = startMillis() ?: return 0
|
||||
val stopMillis = stopMillis() ?: return 0
|
||||
val minutes = ((stopMillis - startMillis) / 60_000).toInt()
|
||||
return minutes.clampDuration()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.takeLastTime(): String {
|
||||
val match = Regex("""(\d{2}:\d{2})""").find(this)
|
||||
return match?.value.orEmpty()
|
||||
}
|
||||
|
||||
private fun parseDateTime(value: String): Date? {
|
||||
val cleaned = value.trim().replace('T', ' ').take(19)
|
||||
val patterns = listOf(
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
"yyyy/MM/dd HH:mm"
|
||||
)
|
||||
|
||||
return patterns.firstNotNullOfOrNull { pattern ->
|
||||
runCatching {
|
||||
SimpleDateFormat(pattern, Locale.US).parse(cleaned)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.clampDuration(): Int {
|
||||
if (this <= 0) return 0
|
||||
return max(1, min(this, MAX_REPLAY_DURATION_MINUTES))
|
||||
}
|
||||
|
||||
private fun decodeMaybeBase64(value: String?): String {
|
||||
val text = value.orEmpty().trim()
|
||||
if (text.isBlank()) return ""
|
||||
|
||||
return runCatching {
|
||||
val decoded = Base64.decode(text, Base64.DEFAULT)
|
||||
String(decoded, StandardCharsets.UTF_8).trim()
|
||||
}.getOrNull()
|
||||
?.takeIf { decoded ->
|
||||
decoded.isNotBlank() &&
|
||||
decoded.any { it.isLetterOrDigit() } &&
|
||||
decoded.count { !it.isISOControl() }.toFloat() / decoded.length.toFloat() > 0.85f
|
||||
}
|
||||
?: text
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class EpgShortResponse(
|
||||
@SerializedName("epg_listings")
|
||||
val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class LiveCategoryDto(
|
||||
@SerializedName("category_id")
|
||||
val categoryId: String,
|
||||
|
||||
@SerializedName("category_name")
|
||||
val categoryName: String? = null,
|
||||
|
||||
@SerializedName("parent_id")
|
||||
val parentId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class LiveStreamDto(
|
||||
@SerializedName("num")
|
||||
val number: Int? = null,
|
||||
|
||||
@SerializedName("name")
|
||||
val name: String? = null,
|
||||
|
||||
@SerializedName("stream_type")
|
||||
val streamType: String? = null,
|
||||
|
||||
@SerializedName("stream_id")
|
||||
val streamId: String,
|
||||
|
||||
@SerializedName("stream_icon")
|
||||
val streamIcon: String? = null,
|
||||
|
||||
@SerializedName("epg_channel_id")
|
||||
val epgChannelId: String? = null,
|
||||
|
||||
@SerializedName("added")
|
||||
val added: String? = null,
|
||||
|
||||
@SerializedName("category_id")
|
||||
val categoryId: String? = null,
|
||||
|
||||
@SerializedName("custom_sid")
|
||||
val customSid: String? = null,
|
||||
|
||||
@SerializedName("tv_archive")
|
||||
val tvArchive: Int? = null,
|
||||
|
||||
@SerializedName("direct_source")
|
||||
val directSource: String? = null
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class SeriesDto(
|
||||
@SerializedName("num")
|
||||
val number: Int? = null,
|
||||
|
||||
@SerializedName("name")
|
||||
val name: String? = null,
|
||||
|
||||
@SerializedName("series_id")
|
||||
val seriesId: String,
|
||||
|
||||
@SerializedName("cover")
|
||||
val cover: String? = null,
|
||||
|
||||
@SerializedName("plot")
|
||||
val plot: String? = null,
|
||||
|
||||
@SerializedName("cast")
|
||||
val cast: String? = null,
|
||||
|
||||
@SerializedName("director")
|
||||
val director: String? = null,
|
||||
|
||||
@SerializedName("genre")
|
||||
val genre: String? = null,
|
||||
|
||||
@SerializedName("releaseDate")
|
||||
val releaseDate: String? = null,
|
||||
|
||||
@SerializedName("last_modified")
|
||||
val lastModified: String? = null,
|
||||
|
||||
@SerializedName("rating")
|
||||
val rating: String? = null,
|
||||
|
||||
@SerializedName("rating_5based")
|
||||
val ratingFiveBased: Float? = null,
|
||||
|
||||
@SerializedName("category_id")
|
||||
val categoryId: String? = null
|
||||
) {
|
||||
fun titleText(): String = name.orEmpty().ifBlank { "Série $seriesId" }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class SeriesInfoResponse(
|
||||
@SerializedName("seasons")
|
||||
val seasons: List<SeriesSeasonDto>? = null,
|
||||
|
||||
@SerializedName("info")
|
||||
val info: SeriesDto? = null,
|
||||
|
||||
@SerializedName("episodes")
|
||||
val episodes: Map<String, List<SeriesEpisodeDto>>? = null
|
||||
) {
|
||||
fun allEpisodes(): List<SeriesEpisodeItem> {
|
||||
return episodes.orEmpty()
|
||||
.flatMap { (seasonKey, episodeList) ->
|
||||
episodeList.map { episode ->
|
||||
SeriesEpisodeItem(
|
||||
seasonKey = seasonKey,
|
||||
episode = episode
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<SeriesEpisodeItem> { it.seasonNumber() ?: Int.MAX_VALUE }
|
||||
.thenBy { it.episode.episodeNum ?: Int.MAX_VALUE }
|
||||
.thenBy { it.episode.title.orEmpty() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SeriesSeasonDto(
|
||||
@SerializedName("air_date")
|
||||
val airDate: String? = null,
|
||||
|
||||
@SerializedName("episode_count")
|
||||
val episodeCount: Int? = null,
|
||||
|
||||
@SerializedName("id")
|
||||
val id: Int? = null,
|
||||
|
||||
@SerializedName("name")
|
||||
val name: String? = null,
|
||||
|
||||
@SerializedName("overview")
|
||||
val overview: String? = null,
|
||||
|
||||
@SerializedName("season_number")
|
||||
val seasonNumber: Int? = null
|
||||
)
|
||||
|
||||
data class SeriesEpisodeDto(
|
||||
@SerializedName("id")
|
||||
val id: String? = null,
|
||||
|
||||
@SerializedName("episode_num")
|
||||
val episodeNum: Int? = null,
|
||||
|
||||
@SerializedName(value = "title", alternate = ["name", "episode_name"])
|
||||
val title: String? = null,
|
||||
|
||||
@SerializedName(value = "container_extension", alternate = ["containerExtension", "extension"])
|
||||
val containerExtension: String? = null,
|
||||
|
||||
@SerializedName("info")
|
||||
val info: SeriesEpisodeInfoDto? = null,
|
||||
|
||||
@SerializedName("custom_sid")
|
||||
val customSid: String? = null,
|
||||
|
||||
@SerializedName("added")
|
||||
val added: String? = null
|
||||
) {
|
||||
fun titleText(): String {
|
||||
val cleanTitle = title
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() && it != "." && it != "-" && it != "..." }
|
||||
|
||||
return cleanTitle ?: episodeNum?.let { "Épisode $it" } ?: "Épisode"
|
||||
}
|
||||
|
||||
fun extensionText(): String {
|
||||
return containerExtension
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.trim('.')
|
||||
.takeIf { it.isNotBlank() && it != "-" }
|
||||
?: "mp4"
|
||||
}
|
||||
}
|
||||
|
||||
data class SeriesEpisodeInfoDto(
|
||||
@SerializedName("movie_image")
|
||||
val movieImage: String? = null,
|
||||
|
||||
@SerializedName("plot")
|
||||
val plot: String? = null,
|
||||
|
||||
@SerializedName("duration_secs")
|
||||
val durationSeconds: Int? = null,
|
||||
|
||||
@SerializedName("duration")
|
||||
val duration: String? = null,
|
||||
|
||||
@SerializedName("releasedate")
|
||||
val releaseDate: String? = null,
|
||||
|
||||
@SerializedName("rating")
|
||||
val rating: String? = null
|
||||
)
|
||||
|
||||
data class SeriesEpisodeItem(
|
||||
val seasonKey: String,
|
||||
val episode: SeriesEpisodeDto
|
||||
) {
|
||||
fun seasonNumber(): Int? = seasonKey.toIntOrNull()
|
||||
|
||||
fun seasonText(): String = seasonNumber()?.let { "Saison $it" } ?: "Saison $seasonKey"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class VodStreamDto(
|
||||
@SerializedName("num")
|
||||
val number: Int? = null,
|
||||
|
||||
@SerializedName("name")
|
||||
val name: String? = null,
|
||||
|
||||
@SerializedName("stream_type")
|
||||
val streamType: String? = null,
|
||||
|
||||
@SerializedName("stream_id")
|
||||
val streamId: String,
|
||||
|
||||
@SerializedName("stream_icon")
|
||||
val streamIcon: String? = null,
|
||||
|
||||
@SerializedName("rating")
|
||||
val rating: String? = null,
|
||||
|
||||
@SerializedName("rating_5based")
|
||||
val ratingFiveBased: Float? = null,
|
||||
|
||||
@SerializedName("added")
|
||||
val added: String? = null,
|
||||
|
||||
@SerializedName("category_id")
|
||||
val categoryId: String? = null,
|
||||
|
||||
@SerializedName("container_extension")
|
||||
val containerExtension: String? = null,
|
||||
|
||||
@SerializedName("custom_sid")
|
||||
val customSid: String? = null,
|
||||
|
||||
@SerializedName("direct_source")
|
||||
val directSource: String? = null
|
||||
) {
|
||||
fun titleText(): String = name.orEmpty().ifBlank { "VOD $streamId" }
|
||||
|
||||
fun extensionText(): String = containerExtension.orEmpty().ifBlank { "mp4" }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class XtreamAccountResponse(
|
||||
@SerializedName("user_info")
|
||||
val userInfo: XtreamUserInfo? = null,
|
||||
|
||||
@SerializedName("server_info")
|
||||
val serverInfo: XtreamServerInfo? = null
|
||||
)
|
||||
|
||||
data class XtreamUserInfo(
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
|
||||
@SerializedName("password")
|
||||
val password: String? = null,
|
||||
|
||||
@SerializedName("message")
|
||||
val message: String? = null,
|
||||
|
||||
@SerializedName("auth")
|
||||
val auth: String? = null,
|
||||
|
||||
@SerializedName("status")
|
||||
val status: String? = null,
|
||||
|
||||
@SerializedName("exp_date")
|
||||
val expirationDate: String? = null,
|
||||
|
||||
@SerializedName("active_cons")
|
||||
val activeConnections: String? = null,
|
||||
|
||||
@SerializedName("max_connections")
|
||||
val maxConnections: String? = null
|
||||
)
|
||||
|
||||
data class XtreamServerInfo(
|
||||
@SerializedName("url")
|
||||
val url: String? = null,
|
||||
|
||||
@SerializedName("port")
|
||||
val port: String? = null,
|
||||
|
||||
@SerializedName("https_port")
|
||||
val httpsPort: String? = null,
|
||||
|
||||
@SerializedName("server_protocol")
|
||||
val serverProtocol: String? = null,
|
||||
|
||||
@SerializedName("timezone")
|
||||
val timezone: String? = null,
|
||||
|
||||
@SerializedName("timestamp_now")
|
||||
val timestampNow: Long? = null,
|
||||
|
||||
@SerializedName("time_now")
|
||||
val timeNow: String? = null
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
data class XtreamConfig(
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package ma.tkgraph.xtreamtv.data.network
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgFullResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgShortResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface XtreamApi {
|
||||
@GET("player_api.php")
|
||||
suspend fun getAccountInfo(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String
|
||||
): XtreamAccountResponse
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getLiveStreams(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_live_streams"
|
||||
): List<LiveStreamDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getLiveCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_live_categories"
|
||||
): List<LiveCategoryDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getVodStreams(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_vod_streams"
|
||||
): List<VodStreamDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getVodCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_vod_categories"
|
||||
): List<LiveCategoryDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getSeries(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series"
|
||||
): List<SeriesDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getSeriesCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series_categories"
|
||||
): List<LiveCategoryDto>
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getSeriesInfo(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series_info",
|
||||
@Query("series_id") seriesId: String
|
||||
): SeriesInfoResponse
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getShortEpg(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_short_epg",
|
||||
@Query("stream_id") streamId: String,
|
||||
@Query("limit") limit: Int = 8
|
||||
): EpgShortResponse
|
||||
|
||||
@GET("player_api.php")
|
||||
suspend fun getFullEpg(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_simple_data_table",
|
||||
@Query("stream_id") streamId: String
|
||||
): EpgFullResponse
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ma.tkgraph.xtreamtv.data.network
|
||||
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
object XtreamApiFactory {
|
||||
fun create(serverUrl: String): XtreamApi {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(serverUrl))
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(XtreamApi::class.java)
|
||||
}
|
||||
|
||||
private fun normalizeBaseUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
val withProtocol = when {
|
||||
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
|
||||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
|
||||
else -> "http://$trimmed"
|
||||
}
|
||||
return withProtocol.trimEnd('/') + "/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
|
||||
class XtreamRepository(
|
||||
private val api: XtreamApi
|
||||
) {
|
||||
suspend fun checkAccount(username: String, password: String): XtreamAccountResponse {
|
||||
return api.getAccountInfo(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadLiveStreams(username: String, password: String): List<LiveStreamDto> {
|
||||
return api.getLiveStreams(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadLiveCategories(username: String, password: String): List<LiveCategoryDto> {
|
||||
return api.getLiveCategories(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadVodStreams(username: String, password: String): List<VodStreamDto> {
|
||||
return api.getVodStreams(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadVodCategories(username: String, password: String): List<LiveCategoryDto> {
|
||||
return api.getVodCategories(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadSeries(username: String, password: String): List<SeriesDto> {
|
||||
return api.getSeries(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadSeriesCategories(username: String, password: String): List<LiveCategoryDto> {
|
||||
return api.getSeriesCategories(username = username, password = password)
|
||||
}
|
||||
|
||||
suspend fun loadSeriesInfo(
|
||||
username: String,
|
||||
password: String,
|
||||
seriesId: String
|
||||
): SeriesInfoResponse {
|
||||
return api.getSeriesInfo(
|
||||
username = username,
|
||||
password = password,
|
||||
seriesId = seriesId
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadShortEpg(
|
||||
username: String,
|
||||
password: String,
|
||||
streamId: String,
|
||||
limit: Int = 8
|
||||
): List<EpgProgramDto> {
|
||||
return api.getShortEpg(
|
||||
username = username,
|
||||
password = password,
|
||||
streamId = streamId,
|
||||
limit = limit
|
||||
).listings.orEmpty()
|
||||
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
|
||||
}
|
||||
|
||||
suspend fun loadFullEpg(
|
||||
username: String,
|
||||
password: String,
|
||||
streamId: String
|
||||
): List<EpgProgramDto> {
|
||||
return api.getFullEpg(
|
||||
username = username,
|
||||
password = password,
|
||||
streamId = streamId
|
||||
).listings.orEmpty()
|
||||
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ma.tkgraph.xtreamtv.data.storage
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
|
||||
class ConfigStorage(context: Context) {
|
||||
|
||||
private val preferences = context.applicationContext.getSharedPreferences(
|
||||
PREFERENCES_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
fun save(config: XtreamConfig) {
|
||||
preferences.edit()
|
||||
.putString(KEY_SERVER_URL, config.serverUrl.trim())
|
||||
.putString(KEY_USERNAME, config.username.trim())
|
||||
.putString(KEY_PASSWORD, config.password.trim())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun load(): XtreamConfig? {
|
||||
val serverUrl = preferences.getString(KEY_SERVER_URL, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
val username = preferences.getString(KEY_USERNAME, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
val password = preferences.getString(KEY_PASSWORD, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
return XtreamConfig(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit()
|
||||
.remove(KEY_SERVER_URL)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_PASSWORD)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadFavoriteStreamIds(): Set<String> = loadStringSet(KEY_FAVORITE_STREAM_IDS)
|
||||
|
||||
fun toggleFavorite(streamId: String): Set<String> {
|
||||
return toggleStringSetValue(
|
||||
key = KEY_FAVORITE_STREAM_IDS,
|
||||
value = streamId
|
||||
)
|
||||
}
|
||||
|
||||
fun loadFavoriteVodIds(): Set<String> = loadStringSet(KEY_FAVORITE_VOD_IDS)
|
||||
|
||||
fun toggleFavoriteVod(vodId: String): Set<String> {
|
||||
return toggleStringSetValue(
|
||||
key = KEY_FAVORITE_VOD_IDS,
|
||||
value = vodId
|
||||
)
|
||||
}
|
||||
|
||||
fun loadFavoriteSeriesIds(): Set<String> = loadStringSet(KEY_FAVORITE_SERIES_IDS)
|
||||
|
||||
fun toggleFavoriteSeries(seriesId: String): Set<String> {
|
||||
return toggleStringSetValue(
|
||||
key = KEY_FAVORITE_SERIES_IDS,
|
||||
value = seriesId
|
||||
)
|
||||
}
|
||||
|
||||
fun savePinnedStreamId(streamId: String) {
|
||||
val cleanedStreamId = streamId.trim()
|
||||
if (cleanedStreamId.isBlank()) return
|
||||
|
||||
preferences.edit()
|
||||
.putString(KEY_PINNED_STREAM_ID, cleanedStreamId)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadPinnedStreamId(): String? {
|
||||
return preferences.getString(KEY_PINNED_STREAM_ID, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun clearPinnedStreamId() {
|
||||
preferences.edit()
|
||||
.remove(KEY_PINNED_STREAM_ID)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun loadStringSet(key: String): Set<String> {
|
||||
return preferences.getStringSet(key, emptySet())
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun toggleStringSetValue(
|
||||
key: String,
|
||||
value: String
|
||||
): Set<String> {
|
||||
val cleanedValue = value.trim()
|
||||
if (cleanedValue.isBlank()) return loadStringSet(key)
|
||||
|
||||
val items = loadStringSet(key).toMutableSet()
|
||||
if (items.contains(cleanedValue)) {
|
||||
items.remove(cleanedValue)
|
||||
} else {
|
||||
items.add(cleanedValue)
|
||||
}
|
||||
|
||||
preferences.edit()
|
||||
.putStringSet(key, items)
|
||||
.apply()
|
||||
|
||||
return items.toSet()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "xtream_config"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_PASSWORD = "password"
|
||||
private const val KEY_FAVORITE_STREAM_IDS = "favorite_stream_ids"
|
||||
private const val KEY_FAVORITE_VOD_IDS = "favorite_vod_ids"
|
||||
private const val KEY_FAVORITE_SERIES_IDS = "favorite_series_ids"
|
||||
private const val KEY_PINNED_STREAM_ID = "pinned_stream_id"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ma.tkgraph.xtreamtv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
|
||||
@Composable
|
||||
fun ChannelLogo(
|
||||
logoUrl: String?,
|
||||
channelName: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cleanedLogoUrl = logoUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
var imageFailed by remember(cleanedLogoUrl) { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.16f),
|
||||
Color.Black.copy(alpha = 0.26f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(3.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (cleanedLogoUrl != null && !imageFailed) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(cleanedLogoUrl)
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
contentDescription = channelName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onError = { imageFailed = true }
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = channelInitials(channelName),
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelInitials(channelName: String?): String {
|
||||
val cleanName = channelName
|
||||
.orEmpty()
|
||||
.replace(Regex("[^A-Za-z0-9À-ÿ ]"), " ")
|
||||
.trim()
|
||||
|
||||
if (cleanName.isBlank()) return "TV"
|
||||
|
||||
val words = cleanName
|
||||
.split(Regex("\\s+"))
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
return when {
|
||||
words.size >= 2 -> "${words[0].first()}${words[1].first()}".uppercase()
|
||||
cleanName.length >= 2 -> cleanName.take(2).uppercase()
|
||||
else -> cleanName.uppercase()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
|
||||
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
initialConfig: XtreamConfig? = null,
|
||||
initialErrorMessage: String? = null,
|
||||
onLoginSuccess: (XtreamConfig, List<LiveStreamDto>, List<LiveCategoryDto>) -> Unit
|
||||
) {
|
||||
var serverUrl by remember(initialConfig?.serverUrl) {
|
||||
mutableStateOf(initialConfig?.serverUrl.orEmpty())
|
||||
}
|
||||
|
||||
var username by remember(initialConfig?.username) {
|
||||
mutableStateOf(initialConfig?.username.orEmpty())
|
||||
}
|
||||
|
||||
var password by remember(initialConfig?.password) {
|
||||
mutableStateOf(initialConfig?.password.orEmpty())
|
||||
}
|
||||
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
var errorMessage by remember(initialErrorMessage) {
|
||||
mutableStateOf(initialErrorMessage)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 720.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = "Xtream TV Player",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Connecte ton accès Xtream compatible pour charger les chaînes Live et les catégories.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Serveur : http://exemple.com:8080") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Utilisateur") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Mot de passe") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
Button(
|
||||
enabled = !isLoading &&
|
||||
serverUrl.isNotBlank() &&
|
||||
username.isNotBlank() &&
|
||||
password.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
|
||||
runCatching {
|
||||
val config = XtreamConfig(
|
||||
serverUrl = serverUrl.trim(),
|
||||
username = username.trim(),
|
||||
password = password.trim()
|
||||
)
|
||||
|
||||
val repository = XtreamRepository(
|
||||
XtreamApiFactory.create(config.serverUrl)
|
||||
)
|
||||
|
||||
val account = repository.checkAccount(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
|
||||
val isAuthorized = account.userInfo?.auth == "1"
|
||||
val isActive = account.userInfo?.status.equals(
|
||||
other = "Active",
|
||||
ignoreCase = true
|
||||
)
|
||||
|
||||
if (!isAuthorized || !isActive) {
|
||||
error("Compte non autorisé ou abonnement inactif.")
|
||||
}
|
||||
|
||||
val categories = runCatching {
|
||||
repository.loadLiveCategories(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val streams = repository.loadLiveStreams(
|
||||
username = config.username,
|
||||
password = config.password
|
||||
)
|
||||
|
||||
onLoginSuccess(config, streams, categories)
|
||||
}.onFailure { throwable ->
|
||||
errorMessage = throwable.message ?: "Connexion impossible au serveur."
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.height(22.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Se connecter")
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import kotlinx.coroutines.delay
|
||||
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
title: String,
|
||||
logoUrl: String?,
|
||||
streamUrl: String,
|
||||
isFavorite: Boolean,
|
||||
isPinned: Boolean,
|
||||
isReplay: Boolean,
|
||||
programTitle: String?,
|
||||
programTime: String?,
|
||||
enableZapping: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onTogglePinned: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var errorMessage by remember(streamUrl) { mutableStateOf<String?>(null) }
|
||||
var resolutionLabel by remember(streamUrl) { mutableStateOf("Résolution : détection...") }
|
||||
var overlayVisible by remember(streamUrl) { mutableStateOf(true) }
|
||||
var backIsArmed by remember(streamUrl) { mutableStateOf(false) }
|
||||
|
||||
fun showOverlayForBack() {
|
||||
overlayVisible = true
|
||||
backIsArmed = true
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
if (backIsArmed) {
|
||||
onBack()
|
||||
} else {
|
||||
showOverlayForBack()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(streamUrl, overlayVisible, backIsArmed) {
|
||||
if (overlayVisible && errorMessage == null) {
|
||||
delay(3_000)
|
||||
overlayVisible = false
|
||||
backIsArmed = false
|
||||
}
|
||||
}
|
||||
|
||||
val player = remember(streamUrl) {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(streamUrl))
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorMessage = "Erreur de lecture : ${error.errorCodeName}"
|
||||
overlayVisible = true
|
||||
backIsArmed = true
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
resolutionLabel = if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
"${videoSize.width} × ${videoSize.height}"
|
||||
} else {
|
||||
"Résolution inconnue"
|
||||
}
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
|
||||
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
if (!enableZapping) return@onPreviewKeyEvent false
|
||||
overlayVisible = true
|
||||
backIsArmed = false
|
||||
onPrevious()
|
||||
true
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
if (!enableZapping) return@onPreviewKeyEvent false
|
||||
overlayVisible = true
|
||||
backIsArmed = false
|
||||
onNext()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { viewContext ->
|
||||
PlayerView(viewContext).apply {
|
||||
this.player = player
|
||||
useController = true
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
keepScreenOn = true
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
requestFocus()
|
||||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
playerView.player = player
|
||||
playerView.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != AndroidKeyEvent.ACTION_UP) return@setOnKeyListener false
|
||||
|
||||
when (keyCode) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT,
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (!enableZapping) return@setOnKeyListener false
|
||||
overlayVisible = true
|
||||
backIsArmed = false
|
||||
onPrevious()
|
||||
true
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (!enableZapping) return@setOnKeyListener false
|
||||
overlayVisible = true
|
||||
backIsArmed = false
|
||||
onNext()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (overlayVisible) {
|
||||
PlayerInfoOverlay(
|
||||
title = title,
|
||||
logoUrl = logoUrl,
|
||||
resolutionLabel = resolutionLabel,
|
||||
isFavorite = isFavorite,
|
||||
isPinned = isPinned,
|
||||
isReplay = isReplay,
|
||||
programTitle = programTitle,
|
||||
programTime = programTime,
|
||||
errorMessage = errorMessage,
|
||||
modifier = Modifier.align(Alignment.TopStart)
|
||||
)
|
||||
|
||||
ControlHintOverlay(
|
||||
enableZapping = enableZapping,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerInfoOverlay(
|
||||
title: String,
|
||||
logoUrl: String?,
|
||||
resolutionLabel: String,
|
||||
isFavorite: Boolean,
|
||||
isPinned: Boolean,
|
||||
isReplay: Boolean,
|
||||
programTitle: String?,
|
||||
programTime: String?,
|
||||
errorMessage: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.padding(28.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
),
|
||||
color = Color.Black.copy(alpha = 0.54f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
ChannelLogo(
|
||||
logoUrl = logoUrl,
|
||||
channelName = title,
|
||||
modifier = Modifier
|
||||
.width(58.dp)
|
||||
.height(58.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
InfoPill(text = resolutionLabel)
|
||||
if (isReplay) InfoPill(text = "Replay")
|
||||
if (isFavorite) InfoPill(text = "Favori")
|
||||
if (isPinned) InfoPill(text = "Démarrage")
|
||||
}
|
||||
|
||||
if (!programTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = buildString {
|
||||
append(programTitle)
|
||||
if (!programTime.isNullOrBlank()) append(" • $programTime")
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlHintOverlay(
|
||||
enableZapping: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.padding(28.dp),
|
||||
color = Color.Black.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (enableZapping) {
|
||||
Text(
|
||||
text = "← / →",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "zapper • Retour : afficher les infos, puis revenir au menu",
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Retour",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "afficher les infos, puis revenir au catalogue",
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoPill(text: String) {
|
||||
Surface(
|
||||
color = Color.White.copy(alpha = 0.14f),
|
||||
shape = RoundedCornerShape(999.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
|
||||
|
||||
@Composable
|
||||
fun ReplayGuideScreen(
|
||||
stream: LiveStreamDto,
|
||||
programs: List<EpgProgramDto>,
|
||||
isLoading: Boolean,
|
||||
errorMessage: String?,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onProgramSelected: (EpgProgramDto) -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
var searchQuery by remember(stream.streamId) { mutableStateOf("") }
|
||||
val streamArchiveEnabled = stream.tvArchive == 1
|
||||
val nowMillis = remember { System.currentTimeMillis() }
|
||||
|
||||
val visiblePrograms = remember(programs, searchQuery, streamArchiveEnabled) {
|
||||
val query = searchQuery.trim()
|
||||
programs
|
||||
.asSequence()
|
||||
.filter { program ->
|
||||
query.isBlank() ||
|
||||
program.titleText().contains(query, ignoreCase = true) ||
|
||||
program.descriptionText().contains(query, ignoreCase = true) ||
|
||||
program.timeRangeText().contains(query, ignoreCase = true) ||
|
||||
program.dayHeaderText().contains(query, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
|
||||
.toList()
|
||||
}
|
||||
|
||||
val guideItems = remember(visiblePrograms) {
|
||||
buildReplayGuideItems(visiblePrograms)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val firstProgramFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(stream.streamId, isLoading) {
|
||||
if (!isLoading && guideItems.any { it is ReplayGuideItem.ProgramItem }) {
|
||||
delay(150)
|
||||
runCatching { firstProgramFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF020617),
|
||||
Color(0xFF071B35),
|
||||
Color(0xFF07111F)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 28.dp, vertical = 18.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
ReplayGuideHeader(
|
||||
stream = stream,
|
||||
programsCount = programs.size,
|
||||
visibleProgramsCount = visiblePrograms.size,
|
||||
searchQuery = searchQuery,
|
||||
onSearchChanged = { searchQuery = it },
|
||||
onBack = onBack
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.White.copy(alpha = 0.045f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
when {
|
||||
isLoading -> ReplayLoading(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
!errorMessage.isNullOrBlank() -> ReplayError(
|
||||
message = errorMessage,
|
||||
onRetry = onRetry,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
programs.isEmpty() -> ReplayEmpty(
|
||||
text = "Aucun programme complet disponible pour cette chaîne. Le fournisseur ne renvoie probablement pas le guide détaillé. Charmant, mais au moins on sait où ça casse.",
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
visiblePrograms.isEmpty() -> ReplayEmpty(
|
||||
text = "Aucune émission ne correspond à la recherche.",
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 24.dp)
|
||||
) {
|
||||
items(
|
||||
items = guideItems,
|
||||
key = { item -> item.stableKey }
|
||||
) { item ->
|
||||
when (item) {
|
||||
is ReplayGuideItem.DayHeader -> DayHeaderRow(item.title)
|
||||
is ReplayGuideItem.ProgramItem -> {
|
||||
ReplayProgramRow(
|
||||
program = item.program,
|
||||
streamArchiveEnabled = streamArchiveEnabled,
|
||||
nowMillis = nowMillis,
|
||||
focusRequester = if (item.index == 0) firstProgramFocusRequester else null,
|
||||
onClick = { onProgramSelected(item.program) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGuideHeader(
|
||||
stream: LiveStreamDto,
|
||||
programsCount: Int,
|
||||
visibleProgramsCount: Int,
|
||||
searchQuery: String,
|
||||
onSearchChanged: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.055f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
ChannelLogo(
|
||||
logoUrl = stream.streamIcon,
|
||||
channelName = title,
|
||||
modifier = Modifier
|
||||
.width(54.dp)
|
||||
.height(54.dp)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Replay",
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = Color(0xFFBAE6FD),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 26.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "$visibleProgramsCount / $programsCount programmes affichés • choisis l’émission à revoir",
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = Color.White.copy(alpha = 0.62f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
DarkReplaySearchField(
|
||||
value = searchQuery,
|
||||
onValueChange = onSearchChanged,
|
||||
label = "Rechercher émission",
|
||||
modifier = Modifier.widthIn(min = 260.dp, max = 390.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.height(38.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Text(
|
||||
text = "Retour",
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DarkReplaySearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = modifier.height(50.dp),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = 0.66f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayHeaderRow(title: String) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
text = title.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() },
|
||||
color = Color(0xFFBAE6FD),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayProgramRow(
|
||||
program: EpgProgramDto,
|
||||
streamArchiveEnabled: Boolean,
|
||||
nowMillis: Long,
|
||||
focusRequester: FocusRequester?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val canPlay = program.canPlayAsReplay(
|
||||
streamArchiveEnabled = streamArchiveEnabled,
|
||||
nowMillis = nowMillis
|
||||
)
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) Color(0xFF22D3EE) else Color.White.copy(alpha = 0.08f),
|
||||
shape = shape
|
||||
),
|
||||
color = if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.052f),
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
TimeBox(program.timeRangeText())
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(enabled = canPlay, onClick = onClick)
|
||||
.focusable(enabled = canPlay)
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = program.titleText(),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 18.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
val description = program.descriptionText()
|
||||
Text(
|
||||
text = if (description.isNotBlank()) description else program.fullDateTimeText(),
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.60f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.width(86.dp)
|
||||
.height(36.dp),
|
||||
enabled = canPlay,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
disabledContainerColor = Color.White.copy(alpha = 0.10f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.34f)
|
||||
),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(
|
||||
text = if (canPlay) "Revoir" else "Indispo",
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeBox(text: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(96.dp)
|
||||
.height(42.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.Black.copy(alpha = 0.28f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayLoading(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = "Chargement du guide replay complet...",
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayError(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.widthIn(max = 620.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Button(onClick = onRetry) {
|
||||
Text(text = "Réessayer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayEmpty(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier.widthIn(max = 720.dp),
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface ReplayGuideItem {
|
||||
val stableKey: String
|
||||
|
||||
data class DayHeader(
|
||||
val title: String
|
||||
) : ReplayGuideItem {
|
||||
override val stableKey: String = "day_$title"
|
||||
}
|
||||
|
||||
data class ProgramItem(
|
||||
val program: EpgProgramDto,
|
||||
val index: Int
|
||||
) : ReplayGuideItem {
|
||||
override val stableKey: String = "program_${program.id ?: program.start.orEmpty()}_${program.titleText()}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildReplayGuideItems(programs: List<EpgProgramDto>): List<ReplayGuideItem> {
|
||||
val items = mutableListOf<ReplayGuideItem>()
|
||||
var lastDay: String? = null
|
||||
|
||||
var programIndex = 0
|
||||
programs.forEach { program ->
|
||||
val day = program.dayHeaderText()
|
||||
if (day != lastDay) {
|
||||
items += ReplayGuideItem.DayHeader(day)
|
||||
lastDay = day
|
||||
}
|
||||
items += ReplayGuideItem.ProgramItem(
|
||||
program = program,
|
||||
index = programIndex
|
||||
)
|
||||
programIndex++
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesEpisodeItem
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
|
||||
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
|
||||
import ma.tkgraph.xtreamtv.util.categorySortNormalizedName
|
||||
import ma.tkgraph.xtreamtv.util.categorySortWeight
|
||||
|
||||
private const val ALL_CATEGORY_ID = "__all__"
|
||||
private const val FAVORITES_SERIES_CATEGORY_ID = "__favorites_series__"
|
||||
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
series: List<SeriesDto>,
|
||||
categories: List<LiveCategoryDto>,
|
||||
isLoading: Boolean,
|
||||
errorMessage: String?,
|
||||
selectedCategoryId: String?,
|
||||
selectedSeriesId: String?,
|
||||
favoriteSeriesIds: Set<String>,
|
||||
categorySearchQuery: String,
|
||||
seriesSearchQuery: String,
|
||||
episodeSearchQuery: String,
|
||||
seriesInfoById: Map<String, SeriesInfoResponse>,
|
||||
loadingSeriesInfoIds: Set<String>,
|
||||
seriesInfoErrors: Map<String, String>,
|
||||
onCategorySelected: (String?) -> Unit,
|
||||
onCategorySearchChanged: (String) -> Unit,
|
||||
onSeriesSearchChanged: (String) -> Unit,
|
||||
onEpisodeSearchChanged: (String) -> Unit,
|
||||
onSeriesFocused: (SeriesDto) -> Unit,
|
||||
onSeriesSelected: (SeriesDto) -> Unit,
|
||||
onToggleFavorite: (SeriesDto) -> Unit,
|
||||
onPlayEpisode: (SeriesDto, SeriesEpisodeItem) -> Unit,
|
||||
onReload: () -> Unit,
|
||||
onRetryInfo: (SeriesDto) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val categoryFilters = remember(series, categories, favoriteSeriesIds) {
|
||||
buildSeriesCategoryFilters(
|
||||
series = series,
|
||||
categories = categories,
|
||||
favoriteSeriesIds = favoriteSeriesIds
|
||||
)
|
||||
}
|
||||
|
||||
val visibleCategories = remember(categoryFilters, categorySearchQuery) {
|
||||
val query = categorySearchQuery.trim()
|
||||
if (query.isBlank()) categoryFilters else categoryFilters.filter { category ->
|
||||
category.name.contains(query, ignoreCase = true) || category.id.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredSeries = remember(series, selectedCategoryId, seriesSearchQuery, favoriteSeriesIds) {
|
||||
filterSeries(
|
||||
series = series,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
favoriteSeriesIds = favoriteSeriesIds,
|
||||
query = seriesSearchQuery
|
||||
)
|
||||
}
|
||||
|
||||
val selectedSeries = selectedSeriesId?.let { id -> series.firstOrNull { it.seriesId == id } }
|
||||
?: filteredSeries.firstOrNull()
|
||||
val selectedCategoryName = categoryFilters.firstOrNull { it.id == selectedCategoryId }?.name ?: "Toutes les séries"
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF020617),
|
||||
Color(0xFF0F172A),
|
||||
Color(0xFF020617)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 28.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SeriesHeader(
|
||||
visibleCount = filteredSeries.size,
|
||||
totalCount = series.size,
|
||||
selectedCategoryName = selectedCategoryName,
|
||||
isLoading = isLoading,
|
||||
onReload = onReload,
|
||||
onBack = onBack
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SeriesCategoryPanel(
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.fillMaxHeight(),
|
||||
categorySearchQuery = categorySearchQuery,
|
||||
onCategorySearchChanged = onCategorySearchChanged,
|
||||
categories = visibleCategories,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
onCategorySelected = onCategorySelected
|
||||
)
|
||||
|
||||
SeriesListPanel(
|
||||
modifier = Modifier
|
||||
.width(430.dp)
|
||||
.fillMaxHeight(),
|
||||
selectedCategoryName = selectedCategoryName,
|
||||
seriesSearchQuery = seriesSearchQuery,
|
||||
onSeriesSearchChanged = onSeriesSearchChanged,
|
||||
series = filteredSeries,
|
||||
selectedSeriesId = selectedSeries?.seriesId,
|
||||
favoriteSeriesIds = favoriteSeriesIds,
|
||||
isLoading = isLoading,
|
||||
errorMessage = errorMessage,
|
||||
onSeriesFocused = onSeriesFocused,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onReload = onReload
|
||||
)
|
||||
|
||||
EpisodesPanel(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
selectedSeries = selectedSeries,
|
||||
seriesInfo = selectedSeries?.seriesId?.let { seriesInfoById[it] },
|
||||
isLoading = selectedSeries?.seriesId?.let { loadingSeriesInfoIds.contains(it) } == true,
|
||||
errorMessage = selectedSeries?.seriesId?.let { seriesInfoErrors[it] },
|
||||
episodeSearchQuery = episodeSearchQuery,
|
||||
onEpisodeSearchChanged = onEpisodeSearchChanged,
|
||||
onRetry = {
|
||||
selectedSeries?.let(onRetryInfo)
|
||||
},
|
||||
onPlayEpisode = { item ->
|
||||
selectedSeries?.let { onPlayEpisode(it, item) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesHeader(
|
||||
visibleCount: Int,
|
||||
totalCount: Int,
|
||||
selectedCategoryName: String,
|
||||
isLoading: Boolean,
|
||||
onReload: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.055f),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Séries",
|
||||
fontSize = 26.sp,
|
||||
lineHeight = 30.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = Color.White,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = "$visibleCount / $totalCount séries • $selectedCategoryName",
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
color = Color.White.copy(alpha = 0.70f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) CircularProgressIndicator(modifier = Modifier.width(28.dp).height(28.dp), strokeWidth = 2.dp)
|
||||
|
||||
CompactOutlinedSeriesButton(text = "Actualiser", width = 112.dp, onClick = onReload)
|
||||
CompactOutlinedSeriesButton(text = "Live", width = 82.dp, onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesCategoryPanel(
|
||||
modifier: Modifier,
|
||||
categorySearchQuery: String,
|
||||
onCategorySearchChanged: (String) -> Unit,
|
||||
categories: List<MediaCategoryFilter>,
|
||||
selectedCategoryId: String?,
|
||||
onCategorySelected: (String?) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = Color.White.copy(alpha = 0.065f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Catégories",
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
DarkSeriesSearchField(
|
||||
value = categorySearchQuery,
|
||||
onValueChange = onCategorySearchChanged,
|
||||
label = "Rechercher catégorie",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 14.dp)
|
||||
) {
|
||||
items(
|
||||
items = categories,
|
||||
key = { it.id ?: ALL_CATEGORY_ID }
|
||||
) { category ->
|
||||
MediaCategoryRow(
|
||||
category = category,
|
||||
selected = category.id == selectedCategoryId,
|
||||
onClick = { onCategorySelected(category.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesListPanel(
|
||||
modifier: Modifier,
|
||||
selectedCategoryName: String,
|
||||
seriesSearchQuery: String,
|
||||
onSeriesSearchChanged: (String) -> Unit,
|
||||
series: List<SeriesDto>,
|
||||
selectedSeriesId: String?,
|
||||
favoriteSeriesIds: Set<String>,
|
||||
isLoading: Boolean,
|
||||
errorMessage: String?,
|
||||
onSeriesFocused: (SeriesDto) -> Unit,
|
||||
onSeriesSelected: (SeriesDto) -> Unit,
|
||||
onToggleFavorite: (SeriesDto) -> Unit,
|
||||
onReload: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = Color.White.copy(alpha = 0.045f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedCategoryName,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
DarkSeriesSearchField(
|
||||
value = seriesSearchQuery,
|
||||
onValueChange = onSeriesSearchChanged,
|
||||
label = "Rechercher série",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
when {
|
||||
isLoading -> LoadingSeriesMessage(text = "Chargement des séries...")
|
||||
errorMessage != null -> SeriesErrorBlock(message = errorMessage, onRetry = onReload)
|
||||
series.isEmpty() -> EmptySeriesMessage("Aucune série trouvée.")
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 18.dp)
|
||||
) {
|
||||
items(
|
||||
items = series,
|
||||
key = { it.seriesId }
|
||||
) { item ->
|
||||
SeriesRow(
|
||||
series = item,
|
||||
selected = item.seriesId == selectedSeriesId,
|
||||
isFavorite = favoriteSeriesIds.contains(item.seriesId),
|
||||
onFocused = { onSeriesFocused(item) },
|
||||
onClick = { onSeriesSelected(item) },
|
||||
onToggleFavorite = { onToggleFavorite(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesRow(
|
||||
series: SeriesDto,
|
||||
selected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val title = series.titleText()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (focused || selected) 2.dp else 1.dp,
|
||||
color = when {
|
||||
focused -> Color(0xFF22D3EE)
|
||||
selected -> Color(0xFF38BDF8)
|
||||
else -> Color.White.copy(alpha = 0.075f)
|
||||
},
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.onFocusChanged { state ->
|
||||
focused = state.isFocused
|
||||
if (state.isFocused) onFocused()
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.focusable(),
|
||||
color = when {
|
||||
selected -> Color(0xFF7C3AED).copy(alpha = 0.25f)
|
||||
focused -> Color.White.copy(alpha = 0.115f)
|
||||
else -> Color.White.copy(alpha = 0.047f)
|
||||
},
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
ChannelLogo(
|
||||
logoUrl = series.cover,
|
||||
channelName = title,
|
||||
modifier = Modifier.width(46.dp).height(46.dp)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
series.number?.let { append("N° $it • ") }
|
||||
append("ID ${series.seriesId}")
|
||||
series.genre?.takeIf { it.isNotBlank() }?.let { append(" • $it") }
|
||||
if (isFavorite) append(" • Favori")
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.58f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
modifier = Modifier.width(48.dp).height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
onClick = onToggleFavorite
|
||||
) {
|
||||
Text(
|
||||
text = if (isFavorite) "★" else "☆",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodesPanel(
|
||||
modifier: Modifier,
|
||||
selectedSeries: SeriesDto?,
|
||||
seriesInfo: SeriesInfoResponse?,
|
||||
isLoading: Boolean,
|
||||
errorMessage: String?,
|
||||
episodeSearchQuery: String,
|
||||
onEpisodeSearchChanged: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onPlayEpisode: (SeriesEpisodeItem) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = Color.White.copy(alpha = 0.045f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedSeries?.titleText() ?: "Sélectionne une série",
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
DarkSeriesSearchField(
|
||||
value = episodeSearchQuery,
|
||||
onValueChange = onEpisodeSearchChanged,
|
||||
label = "Rechercher épisode",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
when {
|
||||
selectedSeries == null -> EmptySeriesMessage("Choisis une série pour voir ses saisons et épisodes.")
|
||||
isLoading -> LoadingSeriesMessage(text = "Chargement des épisodes...")
|
||||
errorMessage != null -> SeriesErrorBlock(message = errorMessage, onRetry = onRetry)
|
||||
seriesInfo == null -> EmptySeriesMessage("Aucun détail chargé pour cette série.")
|
||||
else -> {
|
||||
val episodes = remember(seriesInfo, episodeSearchQuery) {
|
||||
val query = episodeSearchQuery.trim()
|
||||
seriesInfo.allEpisodes().filter { item ->
|
||||
query.isBlank() ||
|
||||
item.episode.titleText().contains(query, ignoreCase = true) ||
|
||||
item.seasonText().contains(query, ignoreCase = true) ||
|
||||
item.episode.episodeNum?.toString().orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.isEmpty()) {
|
||||
EmptySeriesMessage("Aucun épisode trouvé.")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 18.dp)
|
||||
) {
|
||||
val grouped = episodes.groupBy { it.seasonText() }
|
||||
grouped.forEach { (seasonName, items) ->
|
||||
item(key = "header_$seasonName") {
|
||||
Text(
|
||||
text = seasonName,
|
||||
color = Color(0xFFBAE6FD),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = items,
|
||||
key = { item -> "${seasonName}_${item.episode.id ?: item.episode.episodeNum?.toString() ?: item.episode.titleText()}" }
|
||||
) { item ->
|
||||
EpisodeRow(
|
||||
item = item,
|
||||
seriesTitle = selectedSeries.titleText(),
|
||||
onPlay = { onPlayEpisode(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeRow(
|
||||
item: SeriesEpisodeItem,
|
||||
seriesTitle: String?,
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val episode = item.episode
|
||||
val title = buildEpisodeTitle(
|
||||
item = item,
|
||||
seriesTitle = seriesTitle
|
||||
)
|
||||
val meta = buildEpisodeMeta(item)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) Color(0xFF22D3EE) else Color.White.copy(alpha = 0.075f),
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onPlay)
|
||||
.focusable(),
|
||||
color = if (focused) Color.White.copy(alpha = 0.135f) else Color.White.copy(alpha = 0.052f),
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(54.dp)
|
||||
.height(42.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF0F172A).copy(alpha = 0.82f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = episode.episodeNum?.let { "E$it" } ?: "EP",
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = meta,
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.62f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.width(62.dp).height(34.dp),
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
|
||||
onClick = onPlay
|
||||
) {
|
||||
Text(text = "Lire", fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEpisodeTitle(
|
||||
item: SeriesEpisodeItem,
|
||||
seriesTitle: String?
|
||||
): String {
|
||||
val episode = item.episode
|
||||
val rawTitle = episode.title
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() && it != "." && it != "-" && it != "..." }
|
||||
|
||||
val episodeLabel = episode.episodeNum?.let { "Épisode $it" } ?: "Épisode"
|
||||
|
||||
return when {
|
||||
rawTitle != null && rawTitle != episode.episodeNum?.toString() -> "$episodeLabel • $rawTitle"
|
||||
!seriesTitle.isNullOrBlank() -> "$episodeLabel • $seriesTitle"
|
||||
else -> episodeLabel
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEpisodeMeta(item: SeriesEpisodeItem): String {
|
||||
val episode = item.episode
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
parts.add(item.seasonText())
|
||||
parts.add(episode.extensionText().uppercase())
|
||||
|
||||
episode.info?.duration
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && it != "." && it != "-" }
|
||||
?.let { parts.add(it) }
|
||||
|
||||
episode.info?.releaseDate
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && it != "." && it != "-" }
|
||||
?.let { parts.add(it) }
|
||||
|
||||
episode.id
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { parts.add("ID $it") }
|
||||
|
||||
return parts.joinToString(" • ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DarkSeriesSearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = modifier.height(52.dp),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = {
|
||||
Text(text = label, color = Color.White.copy(alpha = 0.68f), fontSize = 12.sp)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(color = Color.White, fontSize = 15.sp, lineHeight = 18.sp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingSeriesMessage(text: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(text = text, color = Color.White.copy(alpha = 0.84f), fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesErrorBlock(message: String, onRetry: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF7F1D1D).copy(alpha = 0.34f)),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(modifier = Modifier.weight(1f), text = message, color = Color.White, style = MaterialTheme.typography.bodyMedium)
|
||||
Button(onClick = onRetry) { Text("Réessayer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySeriesMessage(text: String) {
|
||||
Text(
|
||||
modifier = Modifier.widthIn(max = 720.dp).padding(18.dp),
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactOutlinedSeriesButton(
|
||||
text: String,
|
||||
width: androidx.compose.ui.unit.Dp,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.width(width).height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(text = text, fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSeriesCategoryFilters(
|
||||
series: List<SeriesDto>,
|
||||
categories: List<LiveCategoryDto>,
|
||||
favoriteSeriesIds: Set<String>
|
||||
): List<MediaCategoryFilter> {
|
||||
val namesById = categories
|
||||
.filter { it.categoryId.isNotBlank() }
|
||||
.associate { category ->
|
||||
category.categoryId to category.categoryName.orEmpty().ifBlank { "Catégorie ${category.categoryId}" }
|
||||
}
|
||||
|
||||
val counts = series.groupingBy { item ->
|
||||
item.categoryId?.takeIf { it.isNotBlank() } ?: UNCATEGORIZED_CATEGORY_ID
|
||||
}.eachCount()
|
||||
|
||||
val filters = mutableListOf(
|
||||
MediaCategoryFilter(id = null, name = "Toutes les séries", count = series.size)
|
||||
)
|
||||
|
||||
val favoriteCount = series.count { favoriteSeriesIds.contains(it.seriesId) }
|
||||
if (favoriteCount > 0) {
|
||||
filters.add(MediaCategoryFilter(id = FAVORITES_SERIES_CATEGORY_ID, name = "★ Favoris", count = favoriteCount))
|
||||
}
|
||||
|
||||
counts.keys
|
||||
.filter { it != UNCATEGORIZED_CATEGORY_ID }
|
||||
.sortedWith(
|
||||
compareBy<String>(
|
||||
{ id -> categorySortWeight(namesById[id] ?: "Catégorie $id") },
|
||||
{ id -> categorySortNormalizedName(namesById[id] ?: "Catégorie $id") }
|
||||
)
|
||||
)
|
||||
.forEach { id ->
|
||||
filters.add(MediaCategoryFilter(id = id, name = namesById[id] ?: "Catégorie $id", count = counts[id] ?: 0))
|
||||
}
|
||||
|
||||
val uncategorized = counts[UNCATEGORIZED_CATEGORY_ID] ?: 0
|
||||
if (uncategorized > 0) {
|
||||
filters.add(MediaCategoryFilter(id = UNCATEGORIZED_CATEGORY_ID, name = "Non classées", count = uncategorized))
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
private fun filterSeries(
|
||||
series: List<SeriesDto>,
|
||||
selectedCategoryId: String?,
|
||||
favoriteSeriesIds: Set<String>,
|
||||
query: String
|
||||
): List<SeriesDto> {
|
||||
val cleanQuery = query.trim()
|
||||
return series
|
||||
.asSequence()
|
||||
.filter { item ->
|
||||
when (selectedCategoryId) {
|
||||
null -> true
|
||||
FAVORITES_SERIES_CATEGORY_ID -> favoriteSeriesIds.contains(item.seriesId)
|
||||
UNCATEGORIZED_CATEGORY_ID -> item.categoryId.isNullOrBlank()
|
||||
else -> item.categoryId == selectedCategoryId
|
||||
}
|
||||
}
|
||||
.filter { item ->
|
||||
cleanQuery.isBlank() ||
|
||||
item.name.orEmpty().contains(cleanQuery, ignoreCase = true) ||
|
||||
item.seriesId.contains(cleanQuery, ignoreCase = true) ||
|
||||
item.genre.orEmpty().contains(cleanQuery, ignoreCase = true)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
|
||||
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
|
||||
import ma.tkgraph.xtreamtv.util.categorySortNormalizedName
|
||||
import ma.tkgraph.xtreamtv.util.categorySortWeight
|
||||
|
||||
private const val ALL_CATEGORY_ID = "__all__"
|
||||
private const val FAVORITES_VOD_CATEGORY_ID = "__favorites_vod__"
|
||||
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
|
||||
|
||||
@Composable
|
||||
fun VodScreen(
|
||||
movies: List<VodStreamDto>,
|
||||
categories: List<LiveCategoryDto>,
|
||||
isLoading: Boolean,
|
||||
errorMessage: String?,
|
||||
selectedCategoryId: String?,
|
||||
selectedMovieId: String?,
|
||||
favoriteMovieIds: Set<String>,
|
||||
categorySearchQuery: String,
|
||||
movieSearchQuery: String,
|
||||
onCategorySelected: (String?) -> Unit,
|
||||
onCategorySearchChanged: (String) -> Unit,
|
||||
onMovieSearchChanged: (String) -> Unit,
|
||||
onMovieFocused: (VodStreamDto) -> Unit,
|
||||
onMovieSelected: (VodStreamDto) -> Unit,
|
||||
onToggleFavorite: (VodStreamDto) -> Unit,
|
||||
onReload: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val categoryFilters = remember(movies, categories, favoriteMovieIds) {
|
||||
buildVodCategoryFilters(
|
||||
movies = movies,
|
||||
categories = categories,
|
||||
favoriteMovieIds = favoriteMovieIds
|
||||
)
|
||||
}
|
||||
|
||||
val visibleCategories = remember(categoryFilters, categorySearchQuery) {
|
||||
val query = categorySearchQuery.trim()
|
||||
if (query.isBlank()) categoryFilters else categoryFilters.filter { category ->
|
||||
category.name.contains(query, ignoreCase = true) || category.id.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredMovies = remember(movies, selectedCategoryId, movieSearchQuery, favoriteMovieIds) {
|
||||
filterVodMovies(
|
||||
movies = movies,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
favoriteMovieIds = favoriteMovieIds,
|
||||
query = movieSearchQuery
|
||||
)
|
||||
}
|
||||
|
||||
val selectedCategoryName = categoryFilters.firstOrNull { it.id == selectedCategoryId }?.name ?: "Toutes les VOD"
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF020617),
|
||||
Color(0xFF111827),
|
||||
Color(0xFF020617)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 28.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
VodHeader(
|
||||
visibleCount = filteredMovies.size,
|
||||
totalCount = movies.size,
|
||||
selectedCategoryName = selectedCategoryName,
|
||||
isLoading = isLoading,
|
||||
onReload = onReload,
|
||||
onBack = onBack
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
VodCategoryPanel(
|
||||
modifier = Modifier
|
||||
.width(310.dp)
|
||||
.fillMaxHeight(),
|
||||
categorySearchQuery = categorySearchQuery,
|
||||
onCategorySearchChanged = onCategorySearchChanged,
|
||||
categories = visibleCategories,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
onCategorySelected = onCategorySelected
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
color = Color.White.copy(alpha = 0.045f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = selectedCategoryName,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Films et VOD disponibles sur ton serveur",
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = Color.White.copy(alpha = 0.58f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
DarkVodSearchField(
|
||||
value = movieSearchQuery,
|
||||
onValueChange = onMovieSearchChanged,
|
||||
label = "Rechercher VOD",
|
||||
modifier = Modifier.widthIn(min = 300.dp, max = 430.dp)
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
isLoading -> LoadingMessage(text = "Chargement des VOD...")
|
||||
errorMessage != null -> ErrorBlock(message = errorMessage, onRetry = onReload)
|
||||
filteredMovies.isEmpty() -> EmptyVodMessage("Aucune VOD trouvée. Change la catégorie ou la recherche, cette idée folle de filtrer fonctionne parfois trop bien.")
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 18.dp)
|
||||
) {
|
||||
items(
|
||||
items = filteredMovies,
|
||||
key = { it.streamId }
|
||||
) { movie ->
|
||||
VodMovieRow(
|
||||
movie = movie,
|
||||
selected = movie.streamId == selectedMovieId,
|
||||
isFavorite = favoriteMovieIds.contains(movie.streamId),
|
||||
onFocused = { onMovieFocused(movie) },
|
||||
onPlay = { onMovieSelected(movie) },
|
||||
onToggleFavorite = { onToggleFavorite(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VodHeader(
|
||||
visibleCount: Int,
|
||||
totalCount: Int,
|
||||
selectedCategoryName: String,
|
||||
isLoading: Boolean,
|
||||
onReload: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.055f),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "VOD",
|
||||
fontSize = 26.sp,
|
||||
lineHeight = 30.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = Color.White,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = "$visibleCount / $totalCount films • $selectedCategoryName",
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
color = Color.White.copy(alpha = 0.70f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) CircularProgressIndicator(modifier = Modifier.width(28.dp).height(28.dp), strokeWidth = 2.dp)
|
||||
|
||||
CompactOutlinedMediaButton(text = "Actualiser", width = 112.dp, onClick = onReload)
|
||||
CompactOutlinedMediaButton(text = "Live", width = 82.dp, onClick = onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VodCategoryPanel(
|
||||
modifier: Modifier,
|
||||
categorySearchQuery: String,
|
||||
onCategorySearchChanged: (String) -> Unit,
|
||||
categories: List<MediaCategoryFilter>,
|
||||
selectedCategoryId: String?,
|
||||
onCategorySelected: (String?) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = Color.White.copy(alpha = 0.065f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Catégories VOD",
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
DarkVodSearchField(
|
||||
value = categorySearchQuery,
|
||||
onValueChange = onCategorySearchChanged,
|
||||
label = "Rechercher catégorie",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 14.dp)
|
||||
) {
|
||||
items(
|
||||
items = categories,
|
||||
key = { it.id ?: ALL_CATEGORY_ID }
|
||||
) { category ->
|
||||
MediaCategoryRow(
|
||||
category = category,
|
||||
selected = category.id == selectedCategoryId,
|
||||
onClick = { onCategorySelected(category.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VodMovieRow(
|
||||
movie: VodStreamDto,
|
||||
selected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val title = movie.titleText()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (focused || selected) 2.dp else 1.dp,
|
||||
color = when {
|
||||
focused -> Color(0xFF22D3EE)
|
||||
selected -> Color(0xFF38BDF8)
|
||||
else -> Color.White.copy(alpha = 0.075f)
|
||||
},
|
||||
shape = shape
|
||||
),
|
||||
color = when {
|
||||
selected -> Color(0xFF7C3AED).copy(alpha = 0.25f)
|
||||
focused -> Color.White.copy(alpha = 0.115f)
|
||||
else -> Color.White.copy(alpha = 0.047f)
|
||||
},
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
ChannelLogo(
|
||||
logoUrl = movie.streamIcon,
|
||||
channelName = title,
|
||||
modifier = Modifier
|
||||
.width(52.dp)
|
||||
.height(52.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.onFocusChanged { state ->
|
||||
focused = state.isFocused
|
||||
if (state.isFocused) onFocused()
|
||||
}
|
||||
.clickable(onClick = onPlay)
|
||||
.focusable()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = buildString {
|
||||
movie.number?.let { append("N° $it • ") }
|
||||
append("ID ${movie.streamId}")
|
||||
append(" • ${movie.extensionText().uppercase()}")
|
||||
movie.rating?.takeIf { it.isNotBlank() }?.let { append(" • Note $it") }
|
||||
if (isFavorite) append(" • Favori")
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = Color.White.copy(alpha = 0.60f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
modifier = Modifier.width(52.dp).height(38.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||
onClick = onToggleFavorite
|
||||
) {
|
||||
Text(
|
||||
text = if (isFavorite) "★" else "☆",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.width(82.dp).height(38.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
|
||||
onClick = onPlay
|
||||
) {
|
||||
Text(text = "Lire", fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DarkVodSearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = modifier.height(52.dp),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = 0.68f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MediaCategoryRow(
|
||||
category: MediaCategoryFilter,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.border(
|
||||
width = if (focused || selected) 2.dp else 1.dp,
|
||||
color = when {
|
||||
focused -> Color(0xFF22D3EE)
|
||||
selected -> Color(0xFF38BDF8)
|
||||
else -> Color.White.copy(alpha = 0.08f)
|
||||
},
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.clickable(onClick = onClick)
|
||||
.focusable(),
|
||||
color = when {
|
||||
selected -> Color(0xFF0EA5E9).copy(alpha = 0.32f)
|
||||
focused -> Color.White.copy(alpha = 0.12f)
|
||||
else -> Color.White.copy(alpha = 0.045f)
|
||||
},
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = category.name,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
color = Color.White,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = category.count.toString(),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.70f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingMessage(text: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(text = text, color = Color.White.copy(alpha = 0.84f), fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF7F1D1D).copy(alpha = 0.34f)),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = message,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(onClick = onRetry) { Text("Réessayer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyVodMessage(text: String) {
|
||||
Text(
|
||||
modifier = Modifier.widthIn(max = 720.dp).padding(18.dp),
|
||||
text = text,
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactOutlinedMediaButton(
|
||||
text: String,
|
||||
width: androidx.compose.ui.unit.Dp,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.width(width).height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class MediaCategoryFilter(
|
||||
val id: String?,
|
||||
val name: String,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
private fun buildVodCategoryFilters(
|
||||
movies: List<VodStreamDto>,
|
||||
categories: List<LiveCategoryDto>,
|
||||
favoriteMovieIds: Set<String>
|
||||
): List<MediaCategoryFilter> {
|
||||
val namesById = categories
|
||||
.filter { it.categoryId.isNotBlank() }
|
||||
.associate { category ->
|
||||
category.categoryId to category.categoryName.orEmpty().ifBlank { "Catégorie ${category.categoryId}" }
|
||||
}
|
||||
|
||||
val counts = movies.groupingBy { movie ->
|
||||
movie.categoryId?.takeIf { it.isNotBlank() } ?: UNCATEGORIZED_CATEGORY_ID
|
||||
}.eachCount()
|
||||
|
||||
val filters = mutableListOf(
|
||||
MediaCategoryFilter(id = null, name = "Toutes les VOD", count = movies.size)
|
||||
)
|
||||
|
||||
val favoriteCount = movies.count { favoriteMovieIds.contains(it.streamId) }
|
||||
if (favoriteCount > 0) {
|
||||
filters.add(MediaCategoryFilter(id = FAVORITES_VOD_CATEGORY_ID, name = "★ Favoris", count = favoriteCount))
|
||||
}
|
||||
|
||||
counts.keys
|
||||
.filter { it != UNCATEGORIZED_CATEGORY_ID }
|
||||
.sortedWith(
|
||||
compareBy<String>(
|
||||
{ id -> categorySortWeight(namesById[id] ?: "Catégorie $id") },
|
||||
{ id -> categorySortNormalizedName(namesById[id] ?: "Catégorie $id") }
|
||||
)
|
||||
)
|
||||
.forEach { id ->
|
||||
filters.add(MediaCategoryFilter(id = id, name = namesById[id] ?: "Catégorie $id", count = counts[id] ?: 0))
|
||||
}
|
||||
|
||||
val uncategorized = counts[UNCATEGORIZED_CATEGORY_ID] ?: 0
|
||||
if (uncategorized > 0) {
|
||||
filters.add(MediaCategoryFilter(id = UNCATEGORIZED_CATEGORY_ID, name = "Non classées", count = uncategorized))
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
private fun filterVodMovies(
|
||||
movies: List<VodStreamDto>,
|
||||
selectedCategoryId: String?,
|
||||
favoriteMovieIds: Set<String>,
|
||||
query: String
|
||||
): List<VodStreamDto> {
|
||||
val cleanQuery = query.trim()
|
||||
return movies
|
||||
.asSequence()
|
||||
.filter { movie ->
|
||||
when (selectedCategoryId) {
|
||||
null -> true
|
||||
FAVORITES_VOD_CATEGORY_ID -> favoriteMovieIds.contains(movie.streamId)
|
||||
UNCATEGORIZED_CATEGORY_ID -> movie.categoryId.isNullOrBlank()
|
||||
else -> movie.categoryId == selectedCategoryId
|
||||
}
|
||||
}
|
||||
.filter { movie ->
|
||||
cleanQuery.isBlank() ||
|
||||
movie.name.orEmpty().contains(cleanQuery, ignoreCase = true) ||
|
||||
movie.streamId.contains(cleanQuery, ignoreCase = true) ||
|
||||
movie.rating.orEmpty().contains(cleanQuery, ignoreCase = true)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ma.tkgraph.xtreamtv.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF60A5FA),
|
||||
secondary = Color(0xFF93C5FD),
|
||||
background = Color(0xFF050816),
|
||||
surface = Color(0xFF101828),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color.White,
|
||||
onSurface = Color.White
|
||||
)
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Color(0xFF2563EB),
|
||||
secondary = Color(0xFF1D4ED8),
|
||||
background = Color(0xFFF8FAFC),
|
||||
surface = Color.White,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFF111827),
|
||||
onSurface = Color(0xFF111827)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun XtreamTvTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
typography = Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Orders IPTV categories in a more useful way for this app:
|
||||
* French categories first, Arabic categories second, everything else after.
|
||||
* Special app categories such as "Toutes" and "Favoris" are still added before this sort
|
||||
* by each screen, so they remain at the top.
|
||||
*/
|
||||
fun categorySortWeight(categoryName: String): Int {
|
||||
val normalized = categorySortNormalizedName(categoryName)
|
||||
|
||||
return when {
|
||||
isFrenchCategory(normalized) -> 0
|
||||
isArabicCategory(categoryName, normalized) -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
fun categorySortNormalizedName(categoryName: String): String {
|
||||
val withoutAccents = Normalizer.normalize(categoryName, Normalizer.Form.NFD)
|
||||
.replace("\\p{Mn}+".toRegex(), "")
|
||||
|
||||
return withoutAccents
|
||||
.lowercase(Locale.ROOT)
|
||||
.replace("|", " ")
|
||||
.replace("-", " ")
|
||||
.replace("_", " ")
|
||||
.replace("/", " ")
|
||||
.replace("\\s+".toRegex(), " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun isFrenchCategory(normalized: String): Boolean {
|
||||
val padded = " $normalized "
|
||||
|
||||
return normalized.contains("france") ||
|
||||
normalized.contains("francais") ||
|
||||
normalized.contains("french") ||
|
||||
normalized.contains("francia") ||
|
||||
padded.contains(" fr ") ||
|
||||
padded.contains(" fra ") ||
|
||||
padded.contains(" french ") ||
|
||||
padded.contains(" eu france ") ||
|
||||
padded.contains(" eu fr ") ||
|
||||
padded.startsWith(" fr ") ||
|
||||
padded.startsWith(" fra ")
|
||||
}
|
||||
|
||||
private fun isArabicCategory(original: String, normalized: String): Boolean {
|
||||
val padded = " $normalized "
|
||||
val containsArabicScript = original.any { char ->
|
||||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC ||
|
||||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_PRESENTATION_FORMS_A ||
|
||||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_PRESENTATION_FORMS_B ||
|
||||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_SUPPLEMENT ||
|
||||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_EXTENDED_A
|
||||
}
|
||||
|
||||
return containsArabicScript ||
|
||||
normalized.contains("arabic") ||
|
||||
normalized.contains("arabe") ||
|
||||
normalized.contains("arab") ||
|
||||
normalized.contains("maghreb") ||
|
||||
normalized.contains("maroc") ||
|
||||
normalized.contains("morocco") ||
|
||||
padded.contains(" ar ") ||
|
||||
padded.startsWith(" ar ") ||
|
||||
padded.contains(" mena ") ||
|
||||
padded.contains(" middle east ")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
|
||||
object XtreamUrlBuilder {
|
||||
fun liveUrl(
|
||||
config: XtreamConfig,
|
||||
streamId: String,
|
||||
extension: String = "m3u8"
|
||||
): String {
|
||||
val server = normalizeServer(config.serverUrl)
|
||||
return "$server/live/${config.username}/${config.password}/$streamId.$extension"
|
||||
}
|
||||
|
||||
fun vodUrl(
|
||||
config: XtreamConfig,
|
||||
streamId: String,
|
||||
extension: String = "mp4"
|
||||
): String {
|
||||
val server = normalizeServer(config.serverUrl)
|
||||
return "$server/movie/${config.username}/${config.password}/$streamId.$extension"
|
||||
}
|
||||
|
||||
fun seriesUrl(
|
||||
config: XtreamConfig,
|
||||
episodeId: String,
|
||||
extension: String = "mp4"
|
||||
): String {
|
||||
val server = normalizeServer(config.serverUrl)
|
||||
return "$server/series/${config.username}/${config.password}/$episodeId.$extension"
|
||||
}
|
||||
|
||||
|
||||
fun timeshiftUrl(
|
||||
config: XtreamConfig,
|
||||
streamId: String,
|
||||
start: String,
|
||||
durationMinutes: Int,
|
||||
extension: String = "ts"
|
||||
): String {
|
||||
val server = normalizeServer(config.serverUrl)
|
||||
val formattedStart = formatTimeshiftStart(start)
|
||||
val safeDuration = durationMinutes.coerceIn(1, 720)
|
||||
return "$server/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
|
||||
}
|
||||
|
||||
private fun formatTimeshiftStart(start: String): String {
|
||||
val match = Regex("(\\d{4}-\\d{2}-\\d{2})[ T](\\d{2}):(\\d{2})").find(start.trim())
|
||||
if (match != null) {
|
||||
val date = match.groupValues[1]
|
||||
val hour = match.groupValues[2]
|
||||
val minute = match.groupValues[3]
|
||||
return "$date:$hour-$minute"
|
||||
}
|
||||
|
||||
return start.trim()
|
||||
.take(16)
|
||||
.replace(" ", ":")
|
||||
.replace(Regex(":(\\d{2})$"), "-$1")
|
||||
}
|
||||
|
||||
private fun normalizeServer(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
val withProtocol = when {
|
||||
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
|
||||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
|
||||
else -> "http://$trimmed"
|
||||
}
|
||||
return withProtocol.trimEnd('/')
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:fillColor="#101828" android:pathData="M0,0h108v108h-108z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M42,30 l34,24 l-34,24 z" />
|
||||
<path android:fillColor="#2563EB" android:pathData="M18,82 h72 v8 h-72z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#020B22" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="fill"
|
||||
android:src="@drawable/imedia_tv_background" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<path android:fillColor="#101828" android:pathData="M0,0h320v180h-320z" />
|
||||
<path android:fillColor="#2563EB" android:pathData="M0,120 C70,80 120,155 190,95 C240,52 280,68 320,38 L320,180 L0,180 Z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M122,53 l76,44 l-76,44 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Xtream TV Player</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<resources>
|
||||
<style name="Theme.XtreamTv" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#4F8CFF</item>
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowDisablePreview">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user