Initial upload: iptv-android-tv-2

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:57:47 +01:00
commit 5c0540e727
90 changed files with 20580 additions and 0 deletions
+62
View File
@@ -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)
}
+2
View File
@@ -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.
+34
View File
@@ -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>
+9
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Xtream TV Player</string>
</resources>
+11
View File
@@ -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>