Initial upload: iptv-android-tv-hermes

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:57:53 +01:00
commit 534d462553
222 changed files with 22516 additions and 0 deletions
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv
import android.content.Context
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
import ma.tkgraph.xtreamtv.data.repository.EpgRepository
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
import ma.tkgraph.xtreamtv.data.repository.LiveRepository
import ma.tkgraph.xtreamtv.data.repository.M3uRepository
import ma.tkgraph.xtreamtv.data.repository.RecentLiveRepository
import ma.tkgraph.xtreamtv.data.repository.SeriesRepository
import ma.tkgraph.xtreamtv.data.repository.UsageRepository
import ma.tkgraph.xtreamtv.data.repository.VodRepository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor
import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Container d'injection de dépendances manuel.
* Pas de Hilt/Dagger pour garder le projet léger.
*/
class AppContainer(context: Context) {
val secureStorage = SecureStorage(context)
val m3uRepository = M3uRepository(secureStorage)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(XtreamBaseUrlInterceptor())
.addInterceptor(XtreamErrorInterceptor())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("http://localhost") // Sera remplacé dynamiquement
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java)
// ─── Repositories ───
val authRepository = AuthRepository(xtreamApi, secureStorage)
val usageRepository = UsageRepository(secureStorage)
val recentLiveRepository = RecentLiveRepository(context)
val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository, usageRepository)
val epgRepository = EpgRepository(xtreamApi, secureStorage)
val vodRepository = VodRepository(xtreamApi, secureStorage, usageRepository)
val seriesRepository = SeriesRepository(xtreamApi, secureStorage, usageRepository)
val favoritesRepository = FavoritesRepository(secureStorage)
}
@@ -0,0 +1,145 @@
package ma.tkgraph.xtreamtv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import ma.tkgraph.xtreamtv.ui.navigation.NavState
import ma.tkgraph.xtreamtv.ui.screens.GlobalSearchScreen
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
import ma.tkgraph.xtreamtv.ui.screens.MainMenuScreen
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.screens.YoutubeScreen
import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme
import ma.tkgraph.xtreamtv.util.SessionManager
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntentExtras()
setContent {
XtreamTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppNavigation()
}
}
}
}
private fun handleIntentExtras() {
val serverUrl = intent.getStringExtra("server_url")
val username = intent.getStringExtra("xtream_user")
val password = intent.getStringExtra("xtream_pass")
if (serverUrl != null && username != null && password != null) {
XtreamApplication.instance.appContainer.secureStorage
.saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig(
serverUrl = serverUrl,
username = username,
password = password
))
}
}
}
/**
* Navigation principale.
* L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen
* qui vérifie le pin au chargement des streams.
*/
@Composable
fun AppNavigation() {
val sessionManager = remember {
SessionManager(XtreamApplication.instance.appContainer.secureStorage)
}
var hasSession by remember {
mutableStateOf(sessionManager.hasValidSession())
}
var navState by remember { mutableStateOf<NavState>(NavState.MainMenu) }
var hasAutoPlayedPin by remember { mutableStateOf(false) }
if (!hasSession) {
LoginScreen(
onLoginSuccess = {
hasSession = true
navState = NavState.MainMenu
}
)
return
}
when (val state = navState) {
is NavState.MainMenu -> MainMenuScreen(
onOpenTv = { navState = NavState.Home() },
onOpenVod = { navState = NavState.Vod() },
onOpenSeries = { navState = NavState.Series() },
onOpenSearch = { navState = NavState.GlobalSearch },
onOpenYoutube = { navState = NavState.Youtube },
onNavigate = { navState = it }
)
is NavState.GlobalSearch -> GlobalSearchScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.Youtube -> YoutubeScreen(
onBack = { navState = NavState.MainMenu }
)
is NavState.Home -> HomeScreen(
focusStreamId = state.focusStreamId,
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu },
hasAutoPlayedPin = hasAutoPlayedPin,
onAutoPlayPin = { hasAutoPlayedPin = true }
)
is NavState.Player -> PlayerScreen(
initialStream = state.stream,
allStreams = state.allStreams,
initialIndex = state.currentIndex,
replayUrl = state.replayUrl,
progressKey = state.progressKey,
progressKeys = state.progressKeys,
onBack = {
navState = state.returnState ?: NavState.Home()
}
)
is NavState.ProgramGuide -> ProgramGuideScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.ReplayGuide -> ReplayGuideScreen(
stream = state.stream,
onBack = { navState = NavState.Home(state.stream.streamId) },
onPlayReplay = { url ->
navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url)
}
)
is NavState.Vod -> VodScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.Series -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.SeriesDetail -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
}
}
@@ -0,0 +1,36 @@
package ma.tkgraph.xtreamtv
import android.app.Application
import timber.log.Timber
/**
* Point d'entrée de l'application iMedia TV+.
* Configure le logging et l'injection de dépendances.
*/
class XtreamApplication : Application() {
/** Container d'injection de dépendances (manual DI). */
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
instance = this
appContainer = AppContainer(this)
initLogging()
}
private fun initLogging() {
if (BuildConfig.ENABLE_LOGGING) {
Timber.plant(Timber.DebugTree())
} else {
// En production : Crashlytics tree (à configurer)
// Timber.plant(CrashReportingTree())
}
}
companion object {
lateinit var instance: XtreamApplication
private set
}
}
@@ -0,0 +1,23 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* Réponse de l'API /player_api.php?username=...&password=...
*/
data class AuthResponse(
@SerializedName("user_info") val userInfo: UserInfo? = null,
val auth: String = "0",
val status: String = ""
)
data class UserInfo(
val username: String = "",
@SerializedName("auth") val auth: Int = 0,
val status: String = "",
@SerializedName("created_at") val createdAt: String = "",
@SerializedName("exp_date") val expDate: String = "",
@SerializedName("is_trial") val isTrial: String = "0",
@SerializedName("max_connections") val maxConnections: String = "0",
@SerializedName("allowed_output_formats") val allowedFormats: List<String> = emptyList()
)
@@ -0,0 +1,55 @@
package ma.tkgraph.xtreamtv.data.model
import android.util.Base64
import com.google.gson.annotations.SerializedName
import java.nio.charset.StandardCharsets
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("start") val start: String? = null,
@SerializedName("end") val end: String? = null,
@SerializedName("start_timestamp") val startTimestamp: String? = null,
@SerializedName("stop_timestamp") val stopTimestamp: String? = null,
@SerializedName("has_archive") val hasArchive: Int? = null,
@SerializedName("now_playing") val nowPlaying: Int? = null,
@SerializedName("lang") val lang: String? = null
) {
fun titleText(): String {
return decodeMaybeBase64(rawTitle).ifBlank { "" }
}
fun startMillis(): Long? {
startTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
fun stopMillis(): Long? {
stopTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
/** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */
fun durationMinutes(): Int {
// Depuis les timestamps Unix (en secondes)
val startSec = startTimestamp?.toLongOrNull() ?: return 120
val stopSec = stopTimestamp?.toLongOrNull() ?: return 120
return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720)
}
}
/** Décode si Base64, sinon retourne tel quel. */
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() }
}
?: text
}
@@ -0,0 +1,11 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class EpgShortResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
data class EpgFullResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
@@ -0,0 +1,12 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une catégorie Live.
*/
data class LiveCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un flux Live (chaîne TV).
*/
data class LiveStreamDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("epg_channel_id") val epgChannelId: String? = null,
@SerializedName("tv_archive") val tvArchive: Int = 0,
@SerializedName("has_archive") val hasArchive: Boolean = false,
@SerializedName("container_extension") val containerExtension: String = "m3u8",
val added: String = "",
@SerializedName("custom_sid") val customSid: String? = null,
@SerializedName("tv_archive_duration") val archiveDuration: Int = 0,
/** URL directe du flux (utilisé en mode M3U). */
val directUrl: String = ""
)
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une série.
*/
data class SeriesDto(
val num: Int = 0,
val name: String = "",
@SerializedName("series_id") val seriesId: Int = 0,
val cover: String? = null,
val plot: String? = null,
val cast: String? = null,
val rating: String? = null,
val genre: String? = null,
val year: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("last_modified") val lastModified: String? = null
)
data class SeriesCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
/**
* Détail d'une série (saisons + épisodes).
*/
data class SeriesInfoDto(
val seasons: List<SeasonDto> = emptyList(),
val episodes: Map<String, List<EpisodeDto>> = emptyMap()
)
data class SeasonDto(
val id: Int = 0,
@SerializedName("season_number") val seasonNumber: Int = 0,
val name: String = ""
)
data class EpisodeDto(
val id: Int = 0,
@SerializedName("episode_num") val episodeNum: Int = 0,
val title: String? = null,
val name: String? = null,
@SerializedName("episode_name") val episodeName: String? = null,
@SerializedName("container_extension") val containerExtension: String = "mp4",
val info: EpisodeInfoDto? = null
)
data class EpisodeInfoDto(
@SerializedName("movie_image") val movieImage: String? = null,
val plot: String? = null,
val duration: String? = null,
val rating: String? = null
)
@@ -0,0 +1,16 @@
package ma.tkgraph.xtreamtv.data.model
/**
* État générique pour tous les écrans.
* Usage : ViewModel expose `StateFlow<UiState<T>>`
*/
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(
val message: String,
val retry: Boolean = true,
val exception: XtreamException? = null
) : UiState<Nothing>
}
@@ -0,0 +1,29 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un film VOD.
*/
data class VodDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "movie",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("container_extension") val containerExtension: String = "mp4",
val rating: String? = null,
val added: String = "",
val description: String? = null,
val year: String? = null,
val genre: String? = null,
val director: String? = null,
val cast: String? = null
)
data class VodCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
@@ -0,0 +1,23 @@
package ma.tkgraph.xtreamtv.data.model
/**
* Type de source : serveur Xtream ou fichier M3U.
*/
enum class SourceType { XTREAM, M3U }
/**
* Configuration de connexion (Xtream ou M3U).
*/
data class XtreamConfig(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val m3uUrl: String = "",
val sourceType: SourceType = SourceType.XTREAM
) {
/** Vrai si une configuration valide est présente (quel que soit le mode). */
val isValid: Boolean get() = when (sourceType) {
SourceType.XTREAM -> serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
SourceType.M3U -> m3uUrl.isNotBlank()
}
}
@@ -0,0 +1,25 @@
package ma.tkgraph.xtreamtv.data.model
import java.io.IOException
/**
* Hiérarchie d'exceptions Xtream.
* Étend IOException pour être compatible avec OkHttp Interceptor.
*/
sealed class XtreamException(
message: String,
cause: Throwable? = null
) : IOException(message, cause) {
class NetworkException(message: String) : XtreamException(message)
class TimeoutException(message: String) : XtreamException(message)
class AuthException(message: String) : XtreamException(message)
class ServerException(val code: Int, message: String) : XtreamException(message)
class ParseException(message: String, cause: Throwable) : XtreamException(message, cause)
class StreamUnavailableException(message: String) : XtreamException(message)
}
@@ -0,0 +1,98 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.AuthResponse
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
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.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Interface Retrofit pour l'API Xtream Codes.
* L'URL de base est définie dynamiquement par le Repository.
*/
interface XtreamApi {
@GET("/player_api.php")
suspend fun authenticate(
@Query("username") username: String,
@Query("password") password: String
): AuthResponse
@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 getLiveStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_streams",
@Query("category_id") categoryId: Int? = null
): List<LiveStreamDto>
@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
@GET("/player_api.php")
suspend fun getVodCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_categories"
): List<VodCategoryDto>
@GET("/player_api.php")
suspend fun getVodStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_streams",
@Query("category_id") categoryId: Int? = null
): List<VodDto>
@GET("/player_api.php")
suspend fun getSeriesCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series_categories"
): List<SeriesCategoryDto>
@GET("/player_api.php")
suspend fun getSeries(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series",
@Query("category_id") categoryId: Int? = null
): List<SeriesDto>
@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: Int
): SeriesInfoDto
}
@@ -0,0 +1,80 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.XtreamApplication
import okhttp3.Interceptor
import okhttp3.Response
/**
* Intercepteur OkHttp qui réécrit l'URL de base de Retrofit
* avec l'URL du serveur Xtream stockée dans SecureStorage.
*
* Retrofit est initialisé avec "http://localhost" comme placeholder.
* Cet interceptor remplace "localhost" par le vrai serveur Xtream.
*/
class XtreamBaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config == null || config.serverUrl.isBlank()) {
return chain.proceed(originalRequest)
}
val serverUrl = normalize(config.serverUrl)
val newUrl = originalRequest.url.newBuilder()
.scheme(extractScheme(serverUrl))
.host(extractHost(serverUrl))
.port(extractPort(serverUrl))
.build()
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
}
private fun normalize(raw: String): String {
var value = raw.trim()
.replace("\\s".toRegex(), "")
.replace("\\u00A0", "")
if (!value.startsWith("http://", ignoreCase = true) &&
!value.startsWith("https://", ignoreCase = true)
) {
value = "http://$value"
}
val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true)
if (playerApiIndex >= 0) {
value = value.substring(0, playerApiIndex)
}
return value.trimEnd('/')
}
private fun extractScheme(url: String): String =
if (url.startsWith("https://", ignoreCase = true)) "https" else "http"
private fun extractHost(url: String): String {
return url
.removePrefix("http://")
.removePrefix("https://")
.substringBefore("/")
.substringBefore(":")
.trim()
}
private fun extractPort(url: String): Int {
val withoutProtocol = url
.removePrefix("http://")
.removePrefix("https://")
.substringBefore("/")
val explicitPort = withoutProtocol.substringAfter(":", missingDelimiterValue = "")
.toIntOrNull()
return explicitPort ?: if (extractScheme(url) == "https") 443 else 80
}
}
@@ -0,0 +1,51 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.XtreamException
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
/**
* Intercepteur OkHttp qui transforme les erreurs réseau
* en exceptions typées pour un traitement uniforme.
*/
class XtreamErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return try {
val response = chain.proceed(request)
when {
response.code == 401 || response.code == 403 ->
throw XtreamException.AuthException("Authentication failed")
response.code in 400..499 ->
throw XtreamException.ServerException(
response.code, "Client error: ${response.code}"
)
response.code in 500..599 ->
throw XtreamException.ServerException(
response.code, "Server error: ${response.code}"
)
}
response
} catch (e: SocketTimeoutException) {
Timber.w("Network timeout: ${request.url}")
throw XtreamException.TimeoutException("Connection timed out")
} catch (e: ConnectException) {
Timber.w("Connection refused: ${request.url}")
throw XtreamException.NetworkException("Cannot connect to server")
} catch (e: UnknownHostException) {
Timber.w("DNS resolution failed for: ${request.url}")
throw XtreamException.NetworkException("DNS resolution failed")
} catch (e: XtreamException) {
// Déjà typé, on relance tel quel
throw e
}
}
}
@@ -0,0 +1,156 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.model.XtreamException
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
/**
* Gère l'authentification et la session utilisateur.
*/
class AuthRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
/**
* Tente de se connecter au serveur Xtream.
*
* Le premier login doit sauvegarder temporairement la config avant l'appel API
* parce que XtreamBaseUrlInterceptor lit l'URL serveur depuis SecureStorage.
*/
suspend fun login(serverUrl: String, username: String, password: String): UiState<UserInfo> {
val cleanServerUrl = normalizeServerUrl(serverUrl)
val cleanUsername = username.trim()
val cleanPassword = password.trim()
val previousConfig = storage.getConfig()
val temporaryConfig = XtreamConfig(
sourceType = SourceType.XTREAM,
serverUrl = cleanServerUrl,
username = cleanUsername,
password = cleanPassword
)
return try {
Timber.i("Attempting login for user: %s on %s", cleanUsername, cleanServerUrl)
storage.saveConfig(temporaryConfig)
val response = api.authenticate(cleanUsername, cleanPassword)
// Les serveurs Xtream retournent généralement :
// { "user_info": { "auth": 1, "status": "Active", ... }, "server_info": ... }
// L'ancien code lisait response.auth au niveau racine, qui est souvent absent.
// Résultat : faux "identifiants invalides". Merci le format sauvage.
val authValue = response.userInfo?.auth?.toString()
?: response.auth
val statusValue = response.userInfo?.status
?: response.status
val isAuthenticated = authValue == "1" || authValue.equals("true", ignoreCase = true)
val normalizedStatus = statusValue.trim()
when {
!isAuthenticated -> {
Timber.w("Login failed: auth=%s status=%s", authValue, normalizedStatus)
restorePreviousConfig(previousConfig)
UiState.Error("Identifiants invalides ou format serveur non reconnu.", retry = true)
}
normalizedStatus.isNotBlank() && !normalizedStatus.equals("Active", ignoreCase = true) -> {
Timber.w("Login failed: account status is %s", normalizedStatus)
restorePreviousConfig(previousConfig)
UiState.Error(
"Compte non actif. Statut : $normalizedStatus",
retry = false
)
}
else -> {
storage.saveConfig(temporaryConfig)
val info = UserInfo(
username = response.userInfo?.username?.takeIf { it.isNotBlank() } ?: cleanUsername,
status = normalizedStatus.ifBlank { "Active" },
expDate = response.userInfo?.expDate?.takeIf { it.isNotBlank() },
maxConnections = response.userInfo?.maxConnections?.takeIf { it.isNotBlank() } ?: "1",
isTrial = response.userInfo?.isTrial == "1"
)
Timber.i("Login successful for: %s", cleanUsername)
UiState.Success(info)
}
}
} catch (e: XtreamException.TimeoutException) {
restorePreviousConfig(previousConfig)
UiState.Error("Le serveur ne répond pas. Vérifiez l'URL ou réessayez.", retry = true, exception = e)
} catch (e: XtreamException.NetworkException) {
restorePreviousConfig(previousConfig)
UiState.Error("Impossible de joindre le serveur. Vérifiez l'URL, le port et le réseau.", retry = true, exception = e)
} catch (e: XtreamException.AuthException) {
restorePreviousConfig(previousConfig)
UiState.Error("Identifiants refusés par le serveur.", retry = true, exception = e)
} catch (e: Exception) {
Timber.e(e, "Unexpected error during login")
restorePreviousConfig(previousConfig)
UiState.Error("Connexion échouée : ${e.message ?: "erreur inconnue"}", retry = true)
}
}
private fun restorePreviousConfig(previousConfig: XtreamConfig?) {
if (previousConfig != null) {
storage.saveConfig(previousConfig)
} else {
storage.clearConfigPreservingLocalData()
}
}
private fun normalizeServerUrl(raw: String): String {
var value = raw.trim()
.replace("\\s".toRegex(), "")
.replace("\\u00A0", "")
if (value.isBlank()) return value
if (!value.startsWith("http://", ignoreCase = true) &&
!value.startsWith("https://", ignoreCase = true)
) {
value = "http://$value"
}
val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true)
if (playerApiIndex >= 0) {
value = value.substring(0, playerApiIndex)
}
return value.trimEnd('/')
}
/** Sauvegarde une config M3U. */
fun saveM3uConfig(m3uUrl: String) {
storage.saveConfig(XtreamConfig(
sourceType = SourceType.M3U,
m3uUrl = m3uUrl.trim()
))
}
/** Supprime la session et vide le cache M3U. */
fun logout() {
storage.clearAll()
Timber.i("User logged out")
}
fun hasSession(): Boolean = storage.hasConfig()
fun getSavedConfig(): XtreamConfig? = storage.getConfig()
}
/** Informations utilisateur retournées après connexion. */
data class UserInfo(
val username: String,
val status: String = "Active",
val expDate: String? = null,
val maxConnections: String = "1",
val isTrial: Boolean = false
)
@@ -0,0 +1,41 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
class EpgRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
private fun config(): XtreamConfig? = storage.getCredentials()
suspend fun getShortEpg(streamId: Int): UiState<List<EpgProgramDto>> {
return try {
val c = config() ?: return UiState.Error("Not logged in")
val response = api.getShortEpg(c.username, c.password, streamId = streamId.toString(), limit = 8)
val programs = response.listings.orEmpty()
.map { it.copy(rawTitle = it.rawTitle) } // titleText() sera appelé côté UI
Timber.i("EPG short %d: %d programs", streamId, programs.size)
UiState.Success(programs)
} catch (e: Exception) {
Timber.w(e, "EPG short failed stream=%d", streamId)
UiState.Success(emptyList())
}
}
suspend fun getFullGuide(streamId: Int): UiState<List<EpgProgramDto>> {
return try {
val c = config() ?: return UiState.Error("Not logged in")
val response = api.getFullEpg(c.username, c.password, streamId = streamId.toString())
val programs = response.listings.orEmpty()
UiState.Success(programs)
} catch (e: Exception) {
Timber.w(e, "EPG full failed stream=%d", streamId)
UiState.Success(emptyList())
}
}
}
@@ -0,0 +1,26 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamException
import timber.log.Timber
/**
* Helper pour enrober les appels réseau dans un bloc try-catch
* standardisé qui retourne UiState.Error pour toute exception.
*/
suspend fun <T> catchErrors(block: suspend () -> UiState<T>): UiState<T> {
return try {
block()
} catch (e: XtreamException.TimeoutException) {
UiState.Error("Server is not responding. Try again later.", retry = true, exception = e)
} catch (e: XtreamException.NetworkException) {
UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e)
} catch (e: XtreamException.AuthException) {
UiState.Error("Authentication failed.", retry = true, exception = e)
} catch (e: XtreamException.ServerException) {
UiState.Error("Server error (${e.code}). Contact your provider.", retry = true, exception = e)
} catch (e: Exception) {
Timber.e(e, "Unexpected error")
UiState.Error("An unexpected error occurred.", retry = true)
}
}
@@ -0,0 +1,65 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Gère les favoris pour Live, VOD et Séries.
* Délègue le stockage à SecureStorage.
*/
class FavoritesRepository(private val storage: SecureStorage) {
// ─── Live Favorites ───
fun getFavoriteLiveIds(): Set<Int> = storage.getFavoriteLiveIds()
fun toggleFavoriteLive(streamId: Int): Boolean {
val current = getFavoriteLiveIds().toMutableSet()
return if (streamId in current) {
current.remove(streamId)
storage.setFavoriteLiveIds(current)
false // removed
} else {
current.add(streamId)
storage.setFavoriteLiveIds(current)
true // added
}
}
fun isFavoriteLive(streamId: Int): Boolean = streamId in getFavoriteLiveIds()
// ─── VOD Favorites ───
fun getFavoriteVodIds(): Set<Int> = storage.getFavoriteVodIds()
fun toggleFavoriteVod(streamId: Int): Boolean {
val current = getFavoriteVodIds().toMutableSet()
return if (streamId in current) {
current.remove(streamId)
storage.setFavoriteVodIds(current)
false
} else {
current.add(streamId)
storage.setFavoriteVodIds(current)
true
}
}
// ─── Series Favorites ───
fun getFavoriteSeriesIds(): Set<Int> = storage.getFavoriteSeriesIds()
fun toggleFavoriteSeries(seriesId: Int): Boolean {
val current = getFavoriteSeriesIds().toMutableSet()
return if (seriesId in current) {
current.remove(seriesId)
storage.setFavoriteSeriesIds(current)
false
} else {
current.add(seriesId)
storage.setFavoriteSeriesIds(current)
true
}
}
// ─── Pinned Channel ───
fun getPinnedChannelId(): Int? = storage.getPinnedChannelId()
fun setPinnedChannelId(streamId: Int?) = storage.setPinnedChannelId(streamId)
}
@@ -0,0 +1,73 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
import timber.log.Timber
/**
* Repository pour les flux Live TV.
* Délègue vers XtreamApi ou M3uParser selon la source configurée.
*/
class LiveRepository(
private val api: XtreamApi,
private val storage: SecureStorage,
private val m3uRepository: M3uRepository,
private val usageRepository: UsageRepository
) {
private fun requireConfig(): XtreamConfig? = storage.getConfig()
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
val config = requireConfig() ?: return UiState.Error("Not logged in")
return when (config.sourceType) {
SourceType.XTREAM -> getXtreamCategories(config)
SourceType.M3U -> when (val result = m3uRepository.getCategories()) {
is UiState.Success -> UiState.Success(
CategorySortUtils.sortLiveCategories(result.data, usageRepository.liveCategoryCounts())
)
else -> result
}
}
}
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
val config = requireConfig() ?: return UiState.Error("Not logged in")
return when (config.sourceType) {
SourceType.XTREAM -> getXtreamStreams(config, categoryId)
SourceType.M3U -> when (val result = m3uRepository.getStreams(categoryId)) {
is UiState.Success -> UiState.Success(
UsageSortUtils.sortByUsage(result.data, usageRepository.liveStreamCounts()) { it.streamId.toString() }
)
else -> result
}
}
}
private suspend fun getXtreamCategories(config: XtreamConfig): UiState<List<LiveCategoryDto>> {
return try {
val categories = api.getLiveCategories(config.username, config.password)
val sorted = CategorySortUtils.sortLiveCategories(categories, usageRepository.liveCategoryCounts())
UiState.Success(sorted)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch Xtream categories")
UiState.Error("Erreur de chargement des catégories")
}
}
private suspend fun getXtreamStreams(config: XtreamConfig, categoryId: Int?): UiState<List<LiveStreamDto>> {
return try {
val streams = api.getLiveStreams(config.username, config.password, categoryId = categoryId)
val sorted = UsageSortUtils.sortByUsage(streams, usageRepository.liveStreamCounts()) { it.streamId.toString() }
UiState.Success(sorted)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch Xtream streams")
UiState.Error("Erreur de chargement des chaînes")
}
}
}
@@ -0,0 +1,151 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
* Parse un fichier M3U (Xtream-compatible) en listes de [LiveStreamDto].
*
* Format supporté :
* #EXTM3U
* #EXTINF:-1 tvg-id="…" tvg-name="…" tvg-logo="…" group-title="Catégorie",Nom de la chaîne
* http://url-du-flux.m3u8
*
* Chaque chaîne reçoit un streamId = hash de son URL (pour les favoris/pin).
*/
object M3uParser {
private const val TIMEOUT_SECONDS = 15L
private val client = OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(true)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "VLC/3.0.21 LibVLC/3.0.21")
.build()
chain.proceed(request)
}
.build()
/**
* Télécharge et parse une playlist M3U.
* @param url URL distante ou locale du fichier M3U
* @return Pair (catégories, streams) ou null si échec
*/
suspend fun fetchAndParse(url: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
return try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Timber.w("M3U fetch failed: HTTP ${response.code}")
return null
}
val body = response.body?.string() ?: return null
parse(body)
} catch (e: Exception) {
Timber.e(e, "M3U fetch error: ${e.message}")
null
}
}
/**
* Parse le contenu textuel d'un fichier M3U.
*/
fun parse(content: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>> {
val categoryMap = mutableMapOf<String, Int>() // nom → id (hash)
val categories = mutableListOf<LiveCategoryDto>()
val streams = mutableListOf<LiveStreamDto>()
val lines = content.lines()
var i = 0
var streamIdCounter = 1
while (i < lines.size) {
val line = lines[i].trim()
if (line.startsWith("#EXTINF:")) {
// Extraire les attributs de la ligne EXTINF
val attrs = parseExtInfAttributes(line)
// L'URL est sur la ligne suivante (si elle existe et ne commence pas par #)
val urlLine = if (i + 1 < lines.size) lines[i + 1].trim() else ""
val streamUrl = if (urlLine.isNotBlank() && !urlLine.startsWith("#")) urlLine else ""
if (streamUrl.isNotBlank()) {
val name = attrs["name"] ?: "Chaîne $streamIdCounter"
val logo = attrs["logo"]
val groupTitle = attrs["group-title"]
// Générer un ID stable à partir de l'URL
val stableId = streamUrl.hashCode()
// Fallback si hash trop petit/négatif
val streamId = if (stableId > 0) stableId else streamIdCounter
// Gérer les catégories
if (groupTitle != null && groupTitle !in categoryMap) {
val catId = categoryMap.size + 1
categoryMap[groupTitle] = catId
categories.add(LiveCategoryDto(
categoryId = catId.toString(),
categoryName = groupTitle
))
}
val catIdStr = if (groupTitle != null) categoryMap[groupTitle]?.toString() ?: "" else ""
streams.add(LiveStreamDto(
num = streamIdCounter,
name = name,
streamId = streamId,
streamType = "live",
streamIcon = logo,
categoryId = catIdStr,
containerExtension = streamUrl.substringAfterLast('.', "m3u8"),
directUrl = streamUrl
))
streamIdCounter++
i++ // Sauter la ligne URL
}
}
i++
}
Timber.i("M3U parsed: ${categories.size} categories, ${streams.size} streams")
return Pair(categories, streams)
}
/**
* Extrait les attributs d'une ligne #EXTINF.
* Exemple : #EXTINF:-1 tvg-logo="https://…" group-title="Sports",Nom
*/
private fun parseExtInfAttributes(line: String): Map<String, String> {
val attrs = mutableMapOf<String, String>()
// Extraire les paires clé="valeur"
val regex = Regex("""(\w[\w-]*)\s*=\s*"([^"]*)"""")
for (match in regex.findAll(line)) {
attrs[match.groupValues[1]] = match.groupValues[2]
}
// Extraire le nom de la chaîne (après la dernière virgule)
val commaIdx = line.lastIndexOf(',')
if (commaIdx >= 0 && commaIdx + 1 < line.length) {
val name = line.substring(commaIdx + 1).trim()
if (name.isNotBlank()) {
attrs["name"] = name
}
}
// Fallback sur tvg-name si le nom n'a pas été trouvé après la virgule
if (!attrs.containsKey("name") && attrs.containsKey("tvg-name")) {
attrs["name"] = attrs["tvg-name"]!!
}
return attrs
}
}
@@ -0,0 +1,68 @@
package ma.tkgraph.xtreamtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
/**
* Repository pour les playlists M3U.
*/
class M3uRepository(private val storage: SecureStorage) {
private var cachedCategories: List<LiveCategoryDto> = emptyList()
private var cachedStreams: List<LiveStreamDto> = emptyList()
private val fetchMutex = Mutex()
/** Récupère les catégories depuis le M3U. */
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
return UiState.Success(data.first)
}
/** Récupère les streams depuis le M3U. */
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
val streams = if (categoryId != null) {
data.second.filter { it.categoryId == categoryId.toString() }
} else {
data.second
}
return UiState.Success(streams)
}
/** Invalide le cache M3U. */
fun invalidateCache() {
cachedCategories = emptyList()
cachedStreams = emptyList()
}
private suspend fun fetchIfNeeded(): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
if (cachedStreams.isNotEmpty()) {
return Pair(cachedCategories, cachedStreams)
}
return fetchMutex.withLock {
if (cachedStreams.isNotEmpty()) {
return@withLock Pair(cachedCategories, cachedStreams)
}
val config = storage.getConfig() ?: return@withLock null
if (config.m3uUrl.isBlank()) return@withLock null
Timber.i("Fetching M3U from: ${config.m3uUrl}")
val result = withContext(Dispatchers.IO) {
M3uParser.fetchAndParse(config.m3uUrl)
} ?: return@withLock null
cachedCategories = result.first
cachedStreams = result.second
result
}
}
}
@@ -0,0 +1,258 @@
package ma.tkgraph.xtreamtv.data.repository
import android.content.Context
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
/**
* Sauvegarde locale de la progression des films VOD et des épisodes de séries.
*
* Les clés sont volontairement simples : "vod:<streamId>" ou "series:<seriesId>:<episodeId>".
* Cela évite de mélanger deux vidéos, même si elles ont le même titre.
*/
class PlaybackProgressRepository(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getProgress(key: String): Long = prefs.getLong(key, 0L)
fun getPlaybackProgress(key: String): PlaybackProgressSnapshot {
val positionMs = prefs.getLong(key, 0L)
val durationMs = prefs.getLong(durationKey(key), 0L)
val completed = prefs.getBoolean(completedKey(key), false)
return PlaybackProgressSnapshot(
positionMs = positionMs,
durationMs = durationMs,
isCompleted = completed
)
}
fun saveProgress(key: String, positionMs: Long, durationMs: Long) {
if (positionMs < MIN_SAVED_POSITION_MS) {
clearProgress(key)
return
}
val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs
val shouldMarkCompleted = normalizedDurationMs > 0L &&
(normalizedDurationMs - positionMs <= FINISHED_THRESHOLD_MS || positionMs >= (normalizedDurationMs * FINISHED_RATIO).toLong())
if (shouldMarkCompleted) {
markCompleted(key, normalizedDurationMs)
} else {
prefs.edit()
.putLong(key, positionMs)
.putLong(lastWatchedKey(key), System.currentTimeMillis())
.remove(completedKey(key))
.apply {
if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs)
}
.apply()
}
}
/**
* Stocke les métadonnées nécessaires pour afficher "Continuer à regarder"
* sans recharger tout le catalogue VOD/Séries.
*/
fun saveContinueMetadata(
key: String,
stream: LiveStreamDto,
allStreams: List<LiveStreamDto> = emptyList(),
currentIndex: Int = 0,
progressKeys: List<String> = emptyList(),
playbackUrl: String? = null
) {
val resolvedDirectUrl = playbackUrl?.takeIf { it.isNotBlank() } ?: stream.directUrl
val payload = JSONObject().apply {
put("title", stream.name)
put("imageUrl", stream.streamIcon.orEmpty())
put("streamId", stream.streamId)
put("containerExtension", stream.containerExtension)
put("directUrl", resolvedDirectUrl)
put("currentIndex", currentIndex)
put("allStreams", JSONArray().apply {
allStreams.forEach { item -> put(item.toJson()) }
})
put("progressKeys", JSONArray().apply {
progressKeys.forEach { item -> put(item) }
})
}
prefs.edit().putString(metadataKey(key), payload.toString()).commit()
}
fun getContinueMetadata(key: String): ContinueMetadata? {
val raw = prefs.getString(metadataKey(key), null) ?: return null
return runCatching {
val json = JSONObject(raw)
val allStreamsJson = json.optJSONArray("allStreams") ?: JSONArray()
val allStreams = buildList {
for (i in 0 until allStreamsJson.length()) {
allStreamsJson.optJSONObject(i)?.toLiveStreamDto()?.let { add(it) }
}
}
val progressKeysJson = json.optJSONArray("progressKeys") ?: JSONArray()
val progressKeys = buildList {
for (i in 0 until progressKeysJson.length()) {
add(progressKeysJson.optString(i))
}
}.filter { it.isNotBlank() }
val fallbackStream = LiveStreamDto(
name = json.optString("title"),
streamId = json.optInt("streamId"),
streamIcon = json.optString("imageUrl").takeIf { it.isNotBlank() },
containerExtension = json.optString("containerExtension", "mp4"),
directUrl = json.optString("directUrl").takeIf { it.isNotBlank() } ?: ""
)
ContinueMetadata(
title = json.optString("title"),
imageUrl = json.optString("imageUrl").takeIf { it.isNotBlank() },
stream = allStreams.getOrNull(json.optInt("currentIndex", 0)) ?: fallbackStream,
allStreams = allStreams,
currentIndex = json.optInt("currentIndex", 0).coerceAtLeast(0),
progressKeys = progressKeys
)
}.getOrNull()
}
fun markCompleted(key: String, durationMs: Long = 0L) {
val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs
prefs.edit()
.remove(key)
.remove(lastWatchedKey(key))
.remove(metadataKey(key))
.putBoolean(completedKey(key), true)
.apply {
if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs)
}
.apply()
}
fun hasAnyProgressWithPrefix(prefix: String): Boolean {
return getInProgressKeys(prefix).isNotEmpty()
}
fun getInProgressKeys(prefix: String? = null): List<String> {
return prefs.all.keys
.asSequence()
.filter { key -> prefix == null || key.startsWith(prefix) }
.filterNot { key -> key.startsWith(METADATA_PREFIX) }
.filterNot { key -> key.endsWith(DURATION_SUFFIX) || key.endsWith(COMPLETED_SUFFIX) || key.endsWith(LAST_WATCHED_SUFFIX) }
.filter { key -> prefs.getLong(key, 0L) >= MIN_SAVED_POSITION_MS }
.filterNot { key -> prefs.getBoolean(completedKey(key), false) }
.sortedWith(
compareByDescending<String> { key -> getLastWatched(key) }
.thenBy { key -> key }
)
.toList()
}
fun getLastWatched(key: String): Long = prefs.getLong(lastWatchedKey(key), 0L)
fun getPlaybackProgress(primaryKey: String, fallbackKey: String): PlaybackProgressSnapshot {
val primary = getPlaybackProgress(primaryKey)
return if (primary.hasProgress || primary.isCompleted) primary else getPlaybackProgress(fallbackKey)
}
fun migrateProgress(fromKey: String, toKey: String) {
if (fromKey == toKey) return
val existingProgress = getPlaybackProgress(toKey)
if (existingProgress.hasProgress || existingProgress.isCompleted) return
val oldProgress = getPlaybackProgress(fromKey)
if (!oldProgress.hasProgress && !oldProgress.isCompleted) return
val oldLastWatched = getLastWatched(fromKey)
val oldMetadata = prefs.getString(metadataKey(fromKey), null)
val editor = prefs.edit()
.remove(fromKey)
.remove(durationKey(fromKey))
.remove(completedKey(fromKey))
.remove(lastWatchedKey(fromKey))
.remove(metadataKey(fromKey))
if (oldProgress.isCompleted) {
editor.putBoolean(completedKey(toKey), true)
} else {
editor.putLong(toKey, oldProgress.positionMs)
if (oldLastWatched > 0L) editor.putLong(lastWatchedKey(toKey), oldLastWatched)
if (oldMetadata != null) editor.putString(metadataKey(toKey), oldMetadata)
}
if (oldProgress.durationMs > 0L) {
editor.putLong(durationKey(toKey), oldProgress.durationMs)
}
editor.apply()
}
fun clearProgress(key: String) {
prefs.edit()
.remove(key)
.remove(durationKey(key))
.remove(lastWatchedKey(key))
.remove(metadataKey(key))
.apply()
}
private fun durationKey(key: String): String = "$key$DURATION_SUFFIX"
private fun completedKey(key: String): String = "$key$COMPLETED_SUFFIX"
private fun lastWatchedKey(key: String): String = "$key$LAST_WATCHED_SUFFIX"
private fun metadataKey(key: String): String = "$METADATA_PREFIX$key"
private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply {
put("name", name)
put("streamId", streamId)
put("streamIcon", streamIcon.orEmpty())
put("containerExtension", containerExtension)
put("directUrl", directUrl)
}
private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto(
name = optString("name"),
streamId = optInt("streamId"),
streamIcon = optString("streamIcon").takeIf { it.isNotBlank() },
containerExtension = optString("containerExtension", "mp4"),
directUrl = optString("directUrl")
)
companion object {
private const val PREFS_NAME = "vod_series_playback_progress"
private const val DURATION_SUFFIX = ":duration"
private const val COMPLETED_SUFFIX = ":completed"
private const val LAST_WATCHED_SUFFIX = ":lastWatched"
private const val METADATA_PREFIX = "meta:"
private const val MIN_SAVED_POSITION_MS = 3_000L
private const val FINISHED_THRESHOLD_MS = 30_000L
private const val FINISHED_RATIO = 0.95f
}
}
data class ContinueMetadata(
val title: String,
val imageUrl: String?,
val stream: LiveStreamDto,
val allStreams: List<LiveStreamDto>,
val currentIndex: Int,
val progressKeys: List<String>
)
data class PlaybackProgressSnapshot(
val positionMs: Long,
val durationMs: Long,
val isCompleted: Boolean = false
) {
val hasProgress: Boolean = !isCompleted && positionMs >= 3_000L
val fraction: Float = when {
isCompleted -> 1f
hasProgress && durationMs > 0L -> (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
else -> 0f
}
val percent: Int = when {
isCompleted -> 100
fraction > 0f -> (fraction * 100f).roundToInt().coerceIn(1, 99)
else -> 0
}
}
@@ -0,0 +1,102 @@
package ma.tkgraph.xtreamtv.data.repository
import android.content.Context
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import org.json.JSONArray
import org.json.JSONObject
/**
* Stockage local très léger des dernières chaînes LIVE lancées.
* L'objectif est d'afficher une ligne rapide sur le menu principal sans recharger le catalogue.
*/
class RecentLiveRepository(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun record(stream: LiveStreamDto) {
if (stream.streamId <= 0 || stream.name.isBlank()) return
val now = System.currentTimeMillis()
val existing = getRecentStreams().filterNot { it.stream.streamId == stream.streamId }.toMutableList()
existing.add(0, RecentLiveEntry(stream = stream, lastUsedMs = now))
val payload = JSONArray().apply {
existing.take(MAX_RECENT).forEach { entry -> put(entry.toJson()) }
}
prefs.edit().putString(KEY_RECENT, payload.toString()).apply()
}
fun getRecentStreams(limit: Int = MAX_RECENT): List<RecentLiveEntry> {
val raw = prefs.getString(KEY_RECENT, null) ?: return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.toRecentLiveEntry()?.let { add(it) }
}
}
.filter { it.stream.streamId > 0 && it.stream.name.isNotBlank() }
.distinctBy { it.stream.streamId }
.sortedByDescending { it.lastUsedMs }
.take(limit)
}.getOrElse { emptyList() }
}
private fun RecentLiveEntry.toJson(): JSONObject = JSONObject().apply {
put("lastUsedMs", lastUsedMs)
put("stream", stream.toJson())
}
private fun JSONObject.toRecentLiveEntry(): RecentLiveEntry? {
val streamJson = optJSONObject("stream") ?: return null
return RecentLiveEntry(
stream = streamJson.toLiveStreamDto(),
lastUsedMs = optLong("lastUsedMs", 0L)
)
}
private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply {
put("num", num)
put("name", name)
put("streamId", streamId)
put("streamType", streamType)
put("streamIcon", streamIcon.orEmpty())
put("categoryId", categoryId)
put("epgChannelId", epgChannelId.orEmpty())
put("tvArchive", tvArchive)
put("hasArchive", hasArchive)
put("containerExtension", containerExtension)
put("added", added)
put("customSid", customSid.orEmpty())
put("archiveDuration", archiveDuration)
put("directUrl", directUrl)
}
private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto(
num = optInt("num", 0),
name = optString("name"),
streamId = optInt("streamId", 0),
streamType = optString("streamType"),
streamIcon = optString("streamIcon").takeIf { it.isNotBlank() },
categoryId = optString("categoryId"),
epgChannelId = optString("epgChannelId").takeIf { it.isNotBlank() },
tvArchive = optInt("tvArchive", 0),
hasArchive = optBoolean("hasArchive", false),
containerExtension = optString("containerExtension", "m3u8"),
added = optString("added"),
customSid = optString("customSid").takeIf { it.isNotBlank() },
archiveDuration = optInt("archiveDuration", 0),
directUrl = optString("directUrl")
)
companion object {
private const val PREFS_NAME = "recent_live_channels"
private const val KEY_RECENT = "recent_live_items"
private const val MAX_RECENT = 12
}
}
data class RecentLiveEntry(
val stream: LiveStreamDto,
val lastUsedMs: Long
)
@@ -0,0 +1,52 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
import timber.log.Timber
/**
* Repository pour les séries.
*/
class SeriesRepository(
private val api: XtreamApi,
private val storage: SecureStorage,
private val usageRepository: UsageRepository
) {
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
suspend fun getCategories(): UiState<List<SeriesCategoryDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val cats = api.getSeriesCategories(config.username, config.password)
val sorted = CategorySortUtils.sortSeriesCategories(cats, usageRepository.seriesCategoryCounts())
UiState.Success(sorted)
}
suspend fun getSeriesList(categoryId: Int? = null): UiState<List<SeriesDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val series = api.getSeries(config.username, config.password, categoryId = categoryId)
val sorted = UsageSortUtils.sortByUsage(series, usageRepository.seriesItemCounts()) { it.seriesId.toString() }
UiState.Success(sorted)
}
suspend fun getSeriesInfo(seriesId: Int): UiState<SeriesInfoDto> {
return try {
val config = requireConfig() ?: return UiState.Error("Not logged in")
val info = api.getSeriesInfo(
username = config.username,
password = config.password,
seriesId = seriesId
)
UiState.Success(info)
} catch (e: Exception) {
Timber.e(e, "Failed to load series info for %d", seriesId)
UiState.Error("Failed to load series details.")
}
}
}
@@ -0,0 +1,40 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Stocke et lit les compteurs d'utilisation pour trier l'IPTV comme un être humain
* qui ouvre toujours les mêmes chaînes, mais avec une télécommande.
*/
class UsageRepository(private val storage: SecureStorage) {
fun recordLiveCategory(categoryId: Int?) = record(BUCKET_LIVE_CATEGORY, categoryId)
fun recordVodCategory(categoryId: Int?) = record(BUCKET_VOD_CATEGORY, categoryId)
fun recordSeriesCategory(categoryId: Int?) = record(BUCKET_SERIES_CATEGORY, categoryId)
fun recordLiveStream(streamId: Int) = record(BUCKET_LIVE_STREAM, streamId)
fun recordVodItem(streamId: Int) = record(BUCKET_VOD_ITEM, streamId)
fun recordSeriesItem(seriesId: Int) = record(BUCKET_SERIES_ITEM, seriesId)
fun liveCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_CATEGORY)
fun vodCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_CATEGORY)
fun seriesCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_CATEGORY)
fun liveStreamCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_STREAM)
fun vodItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_ITEM)
fun seriesItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_ITEM)
private fun record(bucket: String, id: Int?) {
if (id == null || id < 0) return
storage.incrementUsageCounter(bucket, id.toString())
}
companion object {
private const val BUCKET_LIVE_CATEGORY = "live_category"
private const val BUCKET_VOD_CATEGORY = "vod_category"
private const val BUCKET_SERIES_CATEGORY = "series_category"
private const val BUCKET_LIVE_STREAM = "live_stream"
private const val BUCKET_VOD_ITEM = "vod_item"
private const val BUCKET_SERIES_ITEM = "series_item"
}
}
@@ -0,0 +1,36 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
import timber.log.Timber
/**
* Repository pour les VOD (films).
*/
class VodRepository(
private val api: XtreamApi,
private val storage: SecureStorage,
private val usageRepository: UsageRepository
) {
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
suspend fun getCategories(): UiState<List<VodCategoryDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val cats = api.getVodCategories(config.username, config.password)
val sorted = CategorySortUtils.sortVodCategories(cats, usageRepository.vodCategoryCounts())
UiState.Success(sorted)
}
suspend fun getMovies(categoryId: Int? = null): UiState<List<VodDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val movies = api.getVodStreams(config.username, config.password, categoryId = categoryId)
val sorted = UsageSortUtils.sortByUsage(movies, usageRepository.vodItemCounts()) { it.streamId.toString() }
UiState.Success(sorted)
}
}
@@ -0,0 +1,107 @@
package ma.tkgraph.xtreamtv.data.repository
import android.content.Context
import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.absoluteValue
class YoutubeRepository(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getVideos(): List<YoutubeVideoItem> {
val raw = prefs.getString(KEY_VIDEOS, "[]").orEmpty()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
add(
YoutubeVideoItem(
id = item.optString("id"),
title = item.optString("title"),
url = item.optString("url"),
videoId = item.optString("videoId").takeIf { it.isNotBlank() },
thumbnailUrl = item.optString("thumbnailUrl").takeIf { it.isNotBlank() },
addedAtMs = item.optLong("addedAtMs", 0L),
lastPlayedAtMs = item.optLong("lastPlayedAtMs", 0L)
)
)
}
}
}.getOrDefault(emptyList())
.filter { it.id.isNotBlank() && it.url.isNotBlank() }
.sortedWith(compareByDescending<YoutubeVideoItem> { it.lastPlayedAtMs }.thenByDescending { it.addedAtMs })
}
fun addVideo(title: String, input: String): Result<YoutubeVideoItem> {
val target = YoutubeUrlUtils.parse(input)
?: return Result.failure(IllegalArgumentException("Lien ou code YouTube invalide"))
val now = System.currentTimeMillis()
val id = target.videoId ?: stableId(target.normalizedUrl)
val normalizedTitle = title.trim().takeIf { it.isNotBlank() }
?: target.videoId?.let { "YouTube $it" }
?: "Vidéo YouTube"
val newItem = YoutubeVideoItem(
id = id,
title = normalizedTitle,
url = target.normalizedUrl,
videoId = target.videoId,
thumbnailUrl = target.thumbnailUrl(),
addedAtMs = now,
lastPlayedAtMs = now
)
val merged = listOf(newItem) + getVideos().filterNot { it.id == id }
saveVideos(merged)
return Result.success(newItem)
}
fun deleteVideo(id: String) {
saveVideos(getVideos().filterNot { it.id == id })
}
fun markPlayed(id: String) {
val now = System.currentTimeMillis()
saveVideos(getVideos().map { item ->
if (item.id == id) item.copy(lastPlayedAtMs = now) else item
})
}
private fun saveVideos(videos: List<YoutubeVideoItem>) {
val array = JSONArray()
videos.take(MAX_SAVED_VIDEOS).forEach { item ->
array.put(
JSONObject()
.put("id", item.id)
.put("title", item.title)
.put("url", item.url)
.put("videoId", item.videoId.orEmpty())
.put("thumbnailUrl", item.thumbnailUrl.orEmpty())
.put("addedAtMs", item.addedAtMs)
.put("lastPlayedAtMs", item.lastPlayedAtMs)
)
}
prefs.edit().putString(KEY_VIDEOS, array.toString()).apply()
}
private fun stableId(value: String): String = "yt_${value.hashCode().absoluteValue}"
companion object {
private const val PREFS_NAME = "youtube_videos"
private const val KEY_VIDEOS = "videos"
private const val MAX_SAVED_VIDEOS = 80
}
}
data class YoutubeVideoItem(
val id: String,
val title: String,
val url: String,
val videoId: String?,
val thumbnailUrl: String?,
val addedAtMs: Long,
val lastPlayedAtMs: Long
)
@@ -0,0 +1,175 @@
package ma.tkgraph.xtreamtv.data.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
/**
* Stockage sécurisé des credentials avec EncryptedSharedPreferences.
*
* - Keys : AES-256 SIV
* - Values : AES-256 GCM
* - MasterKey générée via AndroidKeyStore
*/
class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// ─── Config complète (Xtream ou M3U) ───
fun saveConfig(config: XtreamConfig) {
prefs.edit()
.putString(KEY_SOURCE_TYPE, config.sourceType.name)
.putString(KEY_SERVER, config.serverUrl)
.putString(KEY_USERNAME, config.username)
.putString(KEY_PASSWORD, config.password)
.putString(KEY_M3U_URL, config.m3uUrl)
.apply()
}
fun getConfig(): XtreamConfig? {
// Essayer le nouveau format (avec sourceType)
val sourceTypeStr = prefs.getString(KEY_SOURCE_TYPE, null)
if (sourceTypeStr != null) {
val sourceType = try { SourceType.valueOf(sourceTypeStr) } catch (_: Exception) { SourceType.XTREAM }
return when (sourceType) {
SourceType.XTREAM -> {
val server = prefs.getString(KEY_SERVER, null) ?: return null
val username = prefs.getString(KEY_USERNAME, null) ?: return null
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
}
SourceType.M3U -> {
val m3uUrl = prefs.getString(KEY_M3U_URL, null) ?: return null
XtreamConfig(sourceType = SourceType.M3U, m3uUrl = m3uUrl)
}
}
}
// Fallback : ancien format (sans sourceType, session Xtream directe)
val server = prefs.getString(KEY_SERVER, null) ?: return null
val username = prefs.getString(KEY_USERNAME, null) ?: return null
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
return XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
}
/** Retourne la config en ignorant le type (pour les migrations). */
fun getCredentials(): XtreamConfig? = getConfig()
fun hasConfig(): Boolean = prefs.contains(KEY_SOURCE_TYPE)
fun clearAll() {
prefs.edit().clear().apply()
}
/**
* Efface uniquement la configuration de connexion.
* Garde les favoris, l'historique local, les compteurs d'usage, etc.
*/
fun clearConfigPreservingLocalData() {
prefs.edit()
.remove(KEY_SOURCE_TYPE)
.remove(KEY_SERVER)
.remove(KEY_USERNAME)
.remove(KEY_PASSWORD)
.remove(KEY_M3U_URL)
.apply()
}
// ─── Favorites (Live) ───
fun getFavoriteLiveIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_LIVE, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteLiveIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_LIVE, ids.map { it.toString() }.toSet()).apply()
}
// ─── Favorites (VOD) ───
fun getFavoriteVodIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_VOD, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteVodIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_VOD, ids.map { it.toString() }.toSet()).apply()
}
// ─── Favorites (Series) ───
fun getFavoriteSeriesIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_SERIES, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteSeriesIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_SERIES, ids.map { it.toString() }.toSet()).apply()
}
// ─── Usage counters (tri par utilisation) ───
fun incrementUsageCounter(bucket: String, id: String) {
val cleanId = id.trim()
if (bucket.isBlank() || cleanId.isBlank()) return
val current = getUsageCounters(bucket).toMutableMap()
val nextValue = (current[cleanId] ?: 0) + 1
current[cleanId] = nextValue.coerceAtMost(999_999)
prefs.edit()
.putStringSet(usageKey(bucket), current.map { (key, value) -> "$key=$value" }.toSet())
.commit()
}
fun getUsageCounters(bucket: String): Map<String, Int> {
if (bucket.isBlank()) return emptyMap()
return prefs.getStringSet(usageKey(bucket), emptySet())
?.mapNotNull { raw ->
val key = raw.substringBefore('=', missingDelimiterValue = "").trim()
val value = raw.substringAfter('=', missingDelimiterValue = "").toIntOrNull()
if (key.isNotBlank() && value != null) key to value else null
}
?.toMap() ?: emptyMap()
}
private fun usageKey(bucket: String): String = "usage_counter_$bucket"
// ─── Pinned Channel ───
fun getPinnedChannelId(): Int? {
val id = prefs.getString(KEY_PINNED, null) ?: return null
return id.toIntOrNull()
}
fun setPinnedChannelId(streamId: Int?) {
prefs.edit().putString(KEY_PINNED, streamId?.toString()).apply()
}
companion object {
private const val PREFS_NAME = "imedia_secure_prefs"
private const val KEY_SOURCE_TYPE = "source_type"
private const val KEY_SERVER = "server_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_M3U_URL = "m3u_url"
private const val KEY_FAV_LIVE = "favorite_live"
private const val KEY_FAV_VOD = "favorite_vod"
private const val KEY_FAV_SERIES = "favorite_series"
private const val KEY_PINNED = "pinned_channel"
}
}
@@ -0,0 +1,103 @@
package ma.tkgraph.xtreamtv.ui.components
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
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
private val TvKeyPageUp = Key(AndroidKeyEvent.KEYCODE_PAGE_UP.toLong())
private val TvKeyPageDown = Key(AndroidKeyEvent.KEYCODE_PAGE_DOWN.toLong())
private val TvKeyChannelUp = Key(AndroidKeyEvent.KEYCODE_CHANNEL_UP.toLong())
private val TvKeyChannelDown = Key(AndroidKeyEvent.KEYCODE_CHANNEL_DOWN.toLong())
/**
* Raccourcis Android TV pour les longues listes.
*
* Compatible avec les versions Compose qui n'exposent pas nativeKeyEvent.
* - Appui long Haut : début de liste
* - Appui long Bas : fin de liste
* - Channel+/PageUp : saut vers le haut
* - Channel-/PageDown: saut vers le bas
*/
fun Modifier.tvFastScrollKeys(
enabled: Boolean = true,
onJumpToTop: () -> Unit,
onJumpToBottom: () -> Unit,
onPageUp: () -> Unit,
onPageDown: () -> Unit
): Modifier = composed {
if (!enabled) return@composed this
var upKeyDownCount by remember { mutableIntStateOf(0) }
var downKeyDownCount by remember { mutableIntStateOf(0) }
this.onPreviewKeyEvent { event ->
when (event.type) {
KeyEventType.KeyDown -> {
when (event.key) {
TvKeyChannelUp, TvKeyPageUp -> {
upKeyDownCount = 0
downKeyDownCount = 0
onPageUp()
true
}
TvKeyChannelDown, TvKeyPageDown -> {
upKeyDownCount = 0
downKeyDownCount = 0
onPageDown()
true
}
Key.DirectionUp -> {
upKeyDownCount += 1
downKeyDownCount = 0
// Le premier KeyDown reste la navigation normale.
// Les répétitions envoyées pendant l'appui long déclenchent le saut direct.
if (upKeyDownCount >= 2) {
onJumpToTop()
true
} else {
false
}
}
Key.DirectionDown -> {
downKeyDownCount += 1
upKeyDownCount = 0
if (downKeyDownCount >= 2) {
onJumpToBottom()
true
} else {
false
}
}
else -> {
upKeyDownCount = 0
downKeyDownCount = 0
false
}
}
}
KeyEventType.KeyUp -> {
if (event.key == Key.DirectionUp) upKeyDownCount = 0
if (event.key == Key.DirectionDown) downKeyDownCount = 0
false
}
else -> false
}
}
}
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Modifier qui ajoute un contour lumineux visible quand l'élément ou un de ses enfants a le focus.
* Idéal pour la navigation télécommande Android TV.
*
* @param focusedBorderColor Couleur du contour quand focusé
* @param unfocusedBorderColor Couleur du contour quand pas focusé (transparent par défaut)
* @param borderWidth Épaisseur du contour
* @param cornerRadius Arrondi des coins
*/
fun Modifier.tvFocusHighlight(
focusedBorderColor: Color = Color(0xFFE50914),
unfocusedBorderColor: Color = Color.Transparent,
borderWidth: Dp = 3.dp,
cornerRadius: Dp = 12.dp
): Modifier = composed {
var isFocused by remember { mutableStateOf(false) }
val animatedAlpha by animateFloatAsState(
targetValue = if (isFocused) 1f else 0f,
animationSpec = tween(durationMillis = 150)
)
this
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.then(
if (isFocused) {
Modifier.border(
width = borderWidth,
color = focusedBorderColor.copy(alpha = animatedAlpha),
shape = RoundedCornerShape(cornerRadius)
)
} else {
Modifier.border(
width = borderWidth,
color = unfocusedBorderColor,
shape = RoundedCornerShape(cornerRadius)
)
}
)
}
@@ -0,0 +1,658 @@
package ma.tkgraph.xtreamtv.ui.components
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
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.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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key as ComposeKey
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
/**
* Commande envoyée depuis PlayerScreen vers le mini lecteur YouTube.
* Le nonce force Compose à rejouer la commande même si le même bouton est pressé plusieurs fois.
*/
data class YoutubeRemoteCommand(
val type: YoutubeCommandType,
val nonce: Long = System.nanoTime()
)
enum class YoutubeCommandType {
TogglePlayPause,
SeekBackward,
SeekForward
}
private enum class YoutubeLoadMode {
Embed,
Site
}
/**
* Mémoire légère de session : garde le dernier lien saisi tant que le processus
* de l'application reste ouvert. Rien n'est écrit sur disque.
*/
private object YoutubeMiniPlayerSessionMemory {
var lastInputText: String = DEFAULT_YOUTUBE_WATCH_PREFIX
}
private data class YoutubeEmbedTarget(
val normalizedUrl: String,
val videoId: String?,
val playlistId: String?,
val channelOrSiteUrl: String?
) {
val canUseEmbed: Boolean = videoId != null || playlistId != null
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun YoutubeMiniPlayer(
modifier: Modifier = Modifier,
remoteCommand: YoutubeRemoteCommand? = null,
onClose: () -> Unit
) {
val inputFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val loadButtonFocusRequester = remember { FocusRequester() }
val minimizeButtonFocusRequester = remember { FocusRequester() }
val closeButtonFocusRequester = remember { FocusRequester() }
val minimizedFocusRequester = remember { FocusRequester() }
var isInputFocused by remember { mutableStateOf(false) }
var inputUrl by rememberSaveable(stateSaver = TextFieldValue.Saver) {
val rememberedInput = YoutubeMiniPlayerSessionMemory.lastInputText
.takeIf { it.isNotBlank() }
?: DEFAULT_YOUTUBE_WATCH_PREFIX
mutableStateOf(
TextFieldValue(
text = rememberedInput,
selection = TextRange(rememberedInput.length)
)
)
}
var loadedUrl by rememberSaveable { mutableStateOf("") }
var loadModeName by rememberSaveable { mutableStateOf(YoutubeLoadMode.Embed.name) }
var status by remember { mutableStateOf("V8 focus : ajoutez le code puis Charger") }
var isMinimized by rememberSaveable { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loadMode = remember(loadModeName) {
runCatching { YoutubeLoadMode.valueOf(loadModeName) }.getOrDefault(YoutubeLoadMode.Embed)
}
val target = remember(loadedUrl) { parseYoutubeTarget(loadedUrl) }
fun exitInputAndFocusLoadButton() {
focusManager.clearFocus(force = true)
runCatching { loadButtonFocusRequester.requestFocus() }
status = "Champ quitté : OK sur Charger"
}
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
BackHandler(enabled = !isMinimized) {
if (isInputFocused) {
exitInputAndFocusLoadButton()
} else {
onClose()
}
}
BackHandler(enabled = isMinimized) {
onClose()
}
fun sendCommand(type: YoutubeCommandType) {
val js = when (type) {
YoutubeCommandType.TogglePlayPause -> "if(window.togglePlay){togglePlay();}"
YoutubeCommandType.SeekBackward -> "if(window.seekBy){seekBy(-10);}"
YoutubeCommandType.SeekForward -> "if(window.seekBy){seekBy(10);}"
}
webView?.evaluateJavascript(js, null)
}
fun loadUrl(mode: YoutubeLoadMode) {
val normalized = normalizeYoutubeUrl(inputUrl.text)
YoutubeMiniPlayerSessionMemory.lastInputText = inputUrl.text
loadedUrl = normalized
loadModeName = mode.name
status = when {
normalized.isBlank() -> "Ajoutez le code vidéo après watch?v="
mode == YoutubeLoadMode.Site -> "Chargement du site YouTube"
else -> "Chargement du player intégré"
}
}
fun restoreFromMinimized() {
isMinimized = false
status = "Mode normal : OK = Play/Pause"
}
LaunchedEffect(remoteCommand) {
remoteCommand?.let { sendCommand(it.type) }
}
LaunchedEffect(Unit) {
runCatching { inputFocusRequester.requestFocus() }
}
LaunchedEffect(isMinimized) {
if (isMinimized) {
status = "Mode minimisé : OK pour agrandir"
runCatching { minimizedFocusRequester.requestFocus() }
} else {
runCatching { inputFocusRequester.requestFocus() }
}
}
val surfaceModifier = modifier.then(
if (isMinimized) {
Modifier
.width(220.dp)
.alpha(0.30f)
.focusRequester(minimizedFocusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && (event.key == Key.DirectionCenter || event.key == Key.Enter)) {
restoreFromMinimized()
true
} else if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
onClose()
true
} else {
false
}
}
} else {
Modifier
.width(520.dp)
.onPreviewKeyEvent { event ->
when {
// Filet de sécurité au niveau du conteneur.
isInputFocused && (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) &&
(event.key == Key.DirectionRight || event.key == Key.DirectionDown || event.key == Key.Tab || event.key == Key.Back) -> {
if (event.type == KeyEventType.KeyDown) {
exitInputAndFocusLoadButton()
}
true
}
// OK depuis le champ charge directement, utile sur les Mi Box qui gardent le focus texte.
isInputFocused && event.type == KeyEventType.KeyDown &&
(event.key == Key.DirectionCenter || event.key == Key.Enter) -> {
loadUrl(YoutubeLoadMode.Embed)
exitInputAndFocusLoadButton()
true
}
event.type == KeyEventType.KeyUp && event.key == Key.Back -> {
onClose()
true
}
else -> false
}
}
}
)
Surface(
modifier = surfaceModifier,
color = Color.Black.copy(alpha = if (isMinimized) 0.34f else 0.88f),
shape = RoundedCornerShape(if (isMinimized) 14.dp else 18.dp),
shadowElevation = if (isMinimized) 2.dp else 10.dp
) {
Column(
modifier = Modifier
.border(
1.dp,
Color(0xFF00D5FF).copy(alpha = if (isMinimized) 0.16f else 0.45f),
RoundedCornerShape(if (isMinimized) 14.dp else 18.dp)
)
.padding(if (isMinimized) 5.dp else 12.dp),
verticalArrangement = Arrangement.spacedBy(if (isMinimized) 0.dp else 8.dp)
) {
if (!isMinimized) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "YouTube discret · V8",
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "TV muette · OK Play/Pause · Retour fermer",
color = Color(0xFFB8C7D1),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = onClose,
modifier = Modifier.focusRequester(closeButtonFocusRequester)
) {
Text("Fermer", color = Color.White)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = inputUrl,
onValueChange = {
inputUrl = it
YoutubeMiniPlayerSessionMemory.lastInputText = it.text
},
modifier = Modifier
.weight(1f)
.focusRequester(inputFocusRequester)
.onFocusChanged { isInputFocused = it.isFocused }
.onPreviewKeyEvent { event ->
val exitKeys = event.key == Key.DirectionRight ||
event.key == Key.DirectionDown ||
event.key == Key.Tab ||
event.key == Key.Back
val loadKeys = event.key == Key.DirectionCenter || event.key == Key.Enter
when {
(event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && exitKeys -> {
if (event.type == KeyEventType.KeyDown) {
exitInputAndFocusLoadButton()
}
true
}
event.type == KeyEventType.KeyDown && loadKeys -> {
loadUrl(YoutubeLoadMode.Embed)
exitInputAndFocusLoadButton()
true
}
else -> false
}
},
singleLine = true,
label = { Text("Lien ou code YouTube") },
placeholder = { Text("CTGbG3bygLg") }
)
Button(
onClick = { loadUrl(YoutubeLoadMode.Embed) },
modifier = Modifier.focusRequester(loadButtonFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00A9D6))
) {
Text("Charger")
}
Button(
onClick = { isMinimized = true },
modifier = Modifier.focusRequester(minimizeButtonFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF44515A))
) {
Text("Minimiser")
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(if (isMinimized) 112.dp else 240.dp)
.background(Color.Black, RoundedCornerShape(if (isMinimized) 10.dp else 12.dp)),
contentAlignment = Alignment.Center
) {
if (loadedUrl.isBlank()) {
Text("Aucun lien chargé", color = Color(0xFFB8C7D1))
} else {
ComposeKey("hermes-youtube-webview") {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(false)
settings.userAgentString = DESKTOP_USER_AGENT
runCatching {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val message = consoleMessage?.message().orEmpty()
if (message.startsWith("HERMES_YT:")) {
status = message.removePrefix("HERMES_YT:")
return true
}
return super.onConsoleMessage(consoleMessage)
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
return false
}
override fun onPageFinished(view: WebView?, url: String?) {
if (!status.startsWith("YouTube erreur") && !isMinimized) {
status = if (loadMode == YoutubeLoadMode.Site) {
"Site YouTube chargé : cliquez Play si nécessaire"
} else {
"Player chargé : OK = Play/Pause"
}
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
if (request?.isForMainFrame == true) {
status = "Erreur WebView : ${error?.description ?: "chargement impossible"}"
}
}
}
setBackgroundColor(android.graphics.Color.BLACK)
}.also { webView = it }
},
update = { view ->
webView = view
val currentTarget = target
val tagValue = "${loadedUrl}|${loadMode.name}"
if (view.tag != tagValue) {
view.tag = tagValue
when {
currentTarget == null -> Unit
loadMode == YoutubeLoadMode.Site -> {
view.loadUrl(
currentTarget.toWatchOrNormalizedUrl(),
youtubeHeaders()
)
}
currentTarget.canUseEmbed -> {
view.loadDataWithBaseURL(
HERMES_YOUTUBE_ORIGIN,
buildYoutubeStaticIframeHtml(currentTarget),
"text/html",
"utf-8",
currentTarget.toEmbedUrl()
)
}
else -> {
view.loadUrl(
currentTarget.toWatchOrNormalizedUrl(),
youtubeHeaders()
)
}
}
}
},
modifier = Modifier.fillMaxSize()
)
}
}
if (isMinimized) {
Button(
onClick = { restoreFromMinimized() },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(3.dp)
.focusRequester(minimizedFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color.Black.copy(alpha = 0.58f))
) {
Text("OK agrandir", color = Color.White)
}
}
}
if (!isMinimized) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = { sendCommand(YoutubeCommandType.SeekBackward) }) { Text("-10s") }
Button(onClick = { sendCommand(YoutubeCommandType.TogglePlayPause) }) { Text("Pause / Play") }
Button(onClick = { sendCommand(YoutubeCommandType.SeekForward) }) { Text("+10s") }
Spacer(Modifier.width(8.dp))
Text(
text = status,
color = Color(0xFFB8C7D1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
private const val DEFAULT_YOUTUBE_WATCH_PREFIX = "https://www.youtube.com/watch?v="
private val YOUTUBE_VIDEO_ID_PATTERN = Regex("^[A-Za-z0-9_-]{8,20}$")
private const val HERMES_YOUTUBE_ORIGIN = "https://hermes-youtube.local/"
private const val DESKTOP_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private fun youtubeHeaders(): Map<String, String> = mapOf(
"Referer" to HERMES_YOUTUBE_ORIGIN,
"Origin" to HERMES_YOUTUBE_ORIGIN.trimEnd('/')
)
private fun normalizeYoutubeUrl(raw: String): String {
val value = raw.trim()
if (value.isBlank() || value == DEFAULT_YOUTUBE_WATCH_PREFIX) return ""
return when {
value.startsWith("http://") || value.startsWith("https://") -> value
value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value"
value.startsWith("watch?v=") -> "https://www.youtube.com/$value"
value.startsWith("v=") -> DEFAULT_YOUTUBE_WATCH_PREFIX + value.removePrefix("v=")
YOUTUBE_VIDEO_ID_PATTERN.matches(value) -> DEFAULT_YOUTUBE_WATCH_PREFIX + value
else -> "https://$value"
}
}
private fun parseYoutubeTarget(rawUrl: String): YoutubeEmbedTarget? {
val normalized = normalizeYoutubeUrl(rawUrl)
if (normalized.isBlank()) return null
return runCatching {
val uri = Uri.parse(normalized)
val host = uri.host.orEmpty().lowercase()
val pathSegments = uri.pathSegments
val videoId = when {
host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull()
uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v")
pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1]
else -> null
}?.takeIf { it.length in 8..20 }
val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() }
YoutubeEmbedTarget(
normalizedUrl = normalized,
videoId = videoId,
playlistId = playlistId,
channelOrSiteUrl = normalized
)
}.getOrNull()
}
private fun YoutubeEmbedTarget.toEmbedUrl(): String {
val origin = HERMES_YOUTUBE_ORIGIN.trimEnd('/').urlEncode()
val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin"
return when {
videoId != null -> "https://www.youtube.com/embed/$videoId?$common"
playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${playlistId.urlEncode()}&$common"
else -> normalizedUrl
}
}
private fun YoutubeEmbedTarget.toWatchOrNormalizedUrl(): String {
return when {
videoId != null -> "https://www.youtube.com/watch?v=$videoId"
else -> channelOrSiteUrl ?: normalizedUrl
}
}
private fun buildYoutubeStaticIframeHtml(target: YoutubeEmbedTarget): String {
val embedUrl = target.toEmbedUrl().escapeHtml()
return """
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="referrer" content="strict-origin-when-cross-origin">
<style>
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#ytplayer { width:100%; height:100%; background:#000; border:0; display:block; }
</style>
</head>
<body>
<iframe
id="ytplayer"
src="$embedUrl"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin">
</iframe>
<script src="https://www.youtube.com/iframe_api"></script>
<script>
var player = null;
var lastState = -1;
function logHermes(message) {
try { console.log('HERMES_YT:' + message); } catch(e) {}
}
function onYouTubeIframeAPIReady() {
player = new YT.Player('ytplayer', {
events: {
'onReady': function(event) {
logHermes('Player prêt');
try { event.target.playVideo(); } catch(e) { logHermes('Cliquez Play si nécessaire'); }
},
'onStateChange': function(event) {
lastState = event.data;
if (event.data === YT.PlayerState.PLAYING) logHermes('Lecture YouTube');
if (event.data === YT.PlayerState.PAUSED) logHermes('Pause YouTube');
if (event.data === YT.PlayerState.BUFFERING) logHermes('Chargement YouTube');
},
'onError': function(event) {
logHermes('YouTube erreur ' + event.data + ' : embed refusé');
}
}
});
}
function togglePlay() {
if (!player || !player.getPlayerState) return;
var state = player.getPlayerState();
if (state === YT.PlayerState.PLAYING) {
player.pauseVideo();
} else {
player.playVideo();
}
}
function seekBy(seconds) {
if (!player || !player.getCurrentTime || !player.seekTo) return;
var current = 0;
try { current = player.getCurrentTime(); } catch(e) { current = 0; }
var next = Math.max(0, current + seconds);
player.seekTo(next, true);
}
</script>
</body>
</html>
""".trimIndent()
}
private fun String.escapeHtml(): String =
replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
private fun String.urlEncode(): String = Uri.encode(this)
@@ -0,0 +1,51 @@
package ma.tkgraph.xtreamtv.ui.navigation
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
/**
* État de navigation de l'application.
* Chaque écran est un état avec ses propres données contextuelles.
*/
sealed class NavState {
/** Menu d’accueil principal : TV / VOD / Séries. */
data object MainMenu : NavState()
/** Écran TV Live avec catégories et chaînes. */
data class Home(val focusStreamId: Int? = null) : NavState()
/** Lecteur vidéo. */
data class Player(
val stream: LiveStreamDto,
val allStreams: List<LiveStreamDto> = emptyList(),
val currentIndex: Int = 0,
val replayUrl: String? = null,
val returnState: NavState? = null,
val progressKey: String? = null,
val progressKeys: List<String> = emptyList()
) : NavState()
/** Recherche globale LIVE / VOD / Séries. */
data object GlobalSearch : NavState()
/** Catalogue local de vidéos YouTube. */
data object Youtube : NavState()
/** Guide TV global. */
data object ProgramGuide : NavState()
/** Guide Replay pour une chaîne spécifique. */
data class ReplayGuide(val stream: LiveStreamDto) : NavState()
/** Catalogue VOD (films). */
data class Vod(val initialCategoryId: Int? = null) : NavState()
/** Catalogue séries. */
data class Series(val initialCategoryId: Int? = null) : NavState()
/** Détail d'une série (saisons + épisodes). */
data class SeriesDetail(val series: SeriesDto, val info: SeriesInfoDto) : NavState()
}
@@ -0,0 +1,343 @@
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.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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.navigation.NavState
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResult
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResultType
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchViewModel
import ma.tkgraph.xtreamtv.ui.viewmodels.SearchScope
@Composable
fun GlobalSearchScreen(
viewModel: GlobalSearchViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val searchScope by viewModel.scope.collectAsStateWithLifecycle()
val resultsState by viewModel.results.collectAsStateWithLifecycle()
val searchFocusRequester = remember { FocusRequester() }
val coroutineScope = rememberCoroutineScope()
BackHandler { onBack() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(160)
runCatching { searchFocusRequester.requestFocus() }
}
Surface(modifier = Modifier.fillMaxSize(), color = DesignColors.bg1) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)))
.padding(24.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = DesignColors.orange,
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("◀ Retour", color = DesignColors.muted) }
Spacer(Modifier.width(14.dp))
Text(
"Recherche globale",
color = DesignColors.text,
fontSize = 30.sp,
fontWeight = FontWeight.Black
)
}
Spacer(Modifier.height(18.dp))
OutlinedTextField(
value = query,
onValueChange = viewModel::setQuery,
modifier = Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = DesignColors.orange) },
placeholder = { Text("Rechercher une chaîne, un film ou une série…") }
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
SearchScope.entries.forEach { scope ->
SearchScopeButton(
text = scope.label,
selected = searchScope == scope,
onClick = { viewModel.setScope(scope) }
)
}
Spacer(Modifier.weight(1f))
TextButton(
onClick = viewModel::runSearch,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = DesignColors.orange,
borderWidth = 2.dp,
cornerRadius = 10.dp
)
) {
Text("Lancer", color = DesignColors.orange, fontWeight = FontWeight.Black)
}
}
Spacer(Modifier.height(18.dp))
when (val results = resultsState) {
is UiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = DesignColors.orange)
Spacer(Modifier.height(10.dp))
Text("Recherche en cours…", color = DesignColors.muted)
}
}
is UiState.Error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(results.message, color = MaterialTheme.colorScheme.error)
}
is UiState.Success -> {
val data = results.data
if (query.trim().length < 2) {
SearchEmptyMessage("Tape au moins 2 caractères, choisis LIVE/VOD/SÉRIES/ALL, puis lance la recherche.")
} else if (data.isEmpty()) {
SearchEmptyMessage("Aucun résultat affiché. Lance une recherche ou change de filtre.")
} else {
Text(
"${data.size} résultat(s)",
color = DesignColors.muted,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(data, key = { it.id }) { result ->
SearchResultRow(
result = result,
onClick = {
when (result.type) {
GlobalSearchResultType.Live -> {
val stream = result.liveStream
if (stream != null) {
viewModel.recordLiveUsage(stream)
onNavigate(
NavState.Player(
stream = stream,
allStreams = listOf(stream),
currentIndex = 0,
returnState = NavState.GlobalSearch
)
)
}
}
GlobalSearchResultType.Vod -> {
val movie = result.vod
val stream = movie?.let { viewModel.buildVodStream(it) }
if (movie != null && stream != null) {
viewModel.recordVodUsage(movie.streamId)
onNavigate(
NavState.Player(
stream = stream,
allStreams = emptyList(),
currentIndex = 0,
replayUrl = stream.directUrl,
returnState = NavState.GlobalSearch,
progressKey = "vod:${movie.streamId}"
)
)
}
}
GlobalSearchResultType.Series -> {
val series = result.series
if (series != null) {
viewModel.recordSeriesUsage(series.seriesId)
coroutineScope.launch {
val launch = viewModel.buildSeriesLaunch(series) ?: return@launch
onNavigate(
NavState.Player(
stream = launch.stream,
allStreams = launch.allStreams,
currentIndex = launch.currentIndex,
replayUrl = null,
returnState = NavState.GlobalSearch,
progressKey = launch.progressKey,
progressKeys = launch.progressKeys
)
)
}
}
}
}
}
)
}
}
}
}
else -> SearchEmptyMessage("Recherche prête.")
}
}
}
}
@Composable
private fun SearchScopeButton(text: String, selected: Boolean, onClick: () -> Unit) {
TextButton(
onClick = onClick,
modifier = Modifier
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) DesignColors.orange else DesignColors.stroke,
shape = RoundedCornerShape(10.dp)
)
.tvFocusHighlight(
focusedBorderColor = DesignColors.orange,
borderWidth = 2.dp,
cornerRadius = 10.dp
)
) {
Text(
text,
color = if (selected) DesignColors.orange else DesignColors.muted,
fontWeight = FontWeight.Black
)
}
}
@Composable
private fun SearchEmptyMessage(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = DesignColors.muted, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun SearchResultRow(result: GlobalSearchResult, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val accent = when (result.type) {
GlobalSearchResultType.Live -> DesignColors.orange
GlobalSearchResultType.Vod -> DesignColors.red
GlobalSearchResultType.Series -> DesignColors.green
}
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(86.dp)
.onFocusChanged { focused = it.isFocused || it.hasFocus }
.focusable()
.border(
width = if (focused) 3.dp else 1.dp,
color = if (focused) accent else DesignColors.stroke.copy(alpha = 0.55f),
shape = RoundedCornerShape(16.dp)
),
shape = RoundedCornerShape(16.dp),
color = if (focused) Color(0x22FFFFFF) else DesignColors.panel
) {
Row(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(width = 68.dp, height = 62.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF141414)),
contentAlignment = Alignment.Center
) {
if (!result.imageUrl.isNullOrBlank()) {
coil3.compose.AsyncImage(
model = result.imageUrl,
contentDescription = result.title,
modifier = Modifier.fillMaxSize()
)
} else {
Text(result.type.name.take(3).uppercase(), color = accent, fontWeight = FontWeight.Black)
}
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
result.title,
color = DesignColors.text,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(4.dp))
Text(
result.subtitle,
color = DesignColors.muted,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text("OK", color = accent, fontWeight = FontWeight.Black)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,599 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.text.InputType
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.content.Context
import android.widget.EditText
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.focus.onFocusChanged
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel
private enum class LoginInputField {
SERVER_URL,
USERNAME,
PASSWORD,
M3U_URL
}
/**
* Écran de connexion / configuration.
* Permet de choisir entre serveur Xtream et fichier M3U.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onLoginSuccess: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val serverUrl = viewModel.serverUrl
val username = viewModel.username
val password = viewModel.password
val m3uUrl = viewModel.m3uUrl
val sourceType = viewModel.sourceType
var showModeMenu by remember { mutableStateOf(false) }
var editingField by remember { mutableStateOf<LoginInputField?>(null) }
val serverFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
val m3uFocusRequester = remember { FocusRequester() }
val submitFocusRequester = remember { FocusRequester() }
LaunchedEffect(sourceType) {
runCatching {
if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus()
else m3uFocusRequester.requestFocus()
}
}
LaunchedEffect(uiState) {
if (uiState is UiState.Success) {
onLoginSuccess()
}
}
// Vérifier si des credentials sont déjà stockés
val savedConfig = remember {
XtreamApplication.instance.appContainer.secureStorage.getConfig()
}
when (editingField) {
LoginInputField.SERVER_URL -> NativeLoginInputDialog(
title = "URL du serveur",
value = serverUrl,
hint = "http://serveur:8080",
onDismiss = { editingField = null },
onConfirm = {
viewModel.updateServerUrl(it)
editingField = null
}
)
LoginInputField.USERNAME -> NativeLoginInputDialog(
title = "Identifiant",
value = username,
hint = "Identifiant",
onDismiss = { editingField = null },
onConfirm = {
viewModel.updateUsername(it)
editingField = null
}
)
LoginInputField.PASSWORD -> NativeLoginInputDialog(
title = "Mot de passe",
value = password,
hint = "Mot de passe",
password = true,
onDismiss = { editingField = null },
onConfirm = {
viewModel.updatePassword(it)
editingField = null
}
)
LoginInputField.M3U_URL -> NativeLoginInputDialog(
title = "URL du fichier M3U",
value = m3uUrl,
hint = "https://exemple.com/playlist.m3u",
onDismiss = { editingField = null },
onConfirm = {
viewModel.updateM3uUrl(it)
editingField = null
}
)
null -> Unit
}
Surface(
modifier = Modifier.fillMaxSize(),
color = DesignColors.bg1
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
)
)
.verticalScroll(rememberScrollState())
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(Modifier.height(32.dp))
// Titre avec gradient orange
Text(
text = "iMedia TV+",
style = MaterialTheme.typography.headlineLarge.copy(
brush = Brush.linearGradient(
colors = listOf(DesignColors.orange, DesignColors.orange2)
)
),
fontWeight = FontWeight.Black,
letterSpacing = (-0.055).sp
)
Spacer(Modifier.height(4.dp))
Text(
text = "Configuration",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF888888)
)
Spacer(Modifier.height(24.dp))
// ─── Sélecteur de mode ───
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ModeButton(
label = "Xtream",
isSelected = sourceType == SourceType.XTREAM,
onClick = { viewModel.switchSourceType(SourceType.XTREAM) }
)
ModeButton(
label = "M3U",
isSelected = sourceType == SourceType.M3U,
onClick = { viewModel.switchSourceType(SourceType.M3U) }
)
}
}
}
Spacer(Modifier.height(16.dp))
// ─── Champs selon le mode ───
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
if (sourceType == SourceType.XTREAM) {
// ── Mode Xtream ──
Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(4.dp))
Text(
"Flèches haut/bas pour changer de champ. OK pour saisir.",
color = Color(0xFF888888),
fontSize = 11.sp
)
Spacer(Modifier.height(12.dp))
TvEditableLoginField(
label = "URL du serveur",
value = serverUrl,
hint = "http://serveur:8080",
enabled = uiState !is UiState.Loading,
onClick = { editingField = LoginInputField.SERVER_URL },
modifier = Modifier
.fillMaxWidth()
.focusRequester(serverFocusRequester)
.moveFocusOnDpad(
down = { usernameFocusRequester.requestFocus() }
)
)
Spacer(Modifier.height(8.dp))
TvEditableLoginField(
label = "Identifiant",
value = username,
hint = "Identifiant",
enabled = uiState !is UiState.Loading,
onClick = { editingField = LoginInputField.USERNAME },
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameFocusRequester)
.moveFocusOnDpad(
up = { serverFocusRequester.requestFocus() },
down = { passwordFocusRequester.requestFocus() }
)
)
Spacer(Modifier.height(8.dp))
TvEditableLoginField(
label = "Mot de passe",
value = password,
hint = "Mot de passe",
password = true,
enabled = uiState !is UiState.Loading,
onClick = { editingField = LoginInputField.PASSWORD },
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.moveFocusOnDpad(
up = { usernameFocusRequester.requestFocus() },
down = { submitFocusRequester.requestFocus() },
right = { submitFocusRequester.requestFocus() }
)
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.login() },
enabled = uiState !is UiState.Loading
&& serverUrl.isNotBlank()
&& username.isNotBlank()
&& password.isNotBlank(),
modifier = Modifier
.width(300.dp)
.height(48.dp)
.align(Alignment.CenterHorizontally)
.focusRequester(submitFocusRequester)
.moveFocusOnDpad(
up = { passwordFocusRequester.requestFocus() },
left = { passwordFocusRequester.requestFocus() }
),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914))
) {
if (uiState is UiState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
} else {
// ── Mode M3U ──
Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(4.dp))
Text(
"Flèche bas pour descendre. OK pour saisir.",
color = Color(0xFF888888),
fontSize = 11.sp
)
Spacer(Modifier.height(12.dp))
TvEditableLoginField(
label = "URL du fichier M3U",
value = m3uUrl,
hint = "https://exemple.com/playlist.m3u",
onClick = { editingField = LoginInputField.M3U_URL },
modifier = Modifier
.fillMaxWidth()
.focusRequester(m3uFocusRequester)
.moveFocusOnDpad(
down = { submitFocusRequester.requestFocus() }
)
)
Spacer(Modifier.height(4.dp))
Text(
"Entrez l'URL d'un fichier M3U distant",
color = Color(0xFF666666),
fontSize = 11.sp
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.saveM3uConfig() },
enabled = m3uUrl.isNotBlank(),
modifier = Modifier
.width(300.dp)
.height(48.dp)
.align(Alignment.CenterHorizontally)
.focusRequester(submitFocusRequester)
.moveFocusOnDpad(
up = { m3uFocusRequester.requestFocus() }
),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
) {
Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
// Message d'erreur
if (uiState is UiState.Error) {
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) {
Text(
text = (uiState as UiState.Error).message,
modifier = Modifier.padding(16.dp),
color = Color(0xFFFF5252)
)
}
}
// Si déjà connecté, afficher les infos
if (savedConfig != null) {
Spacer(Modifier.height(16.dp))
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFF1A3A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
TextButton(onClick = { viewModel.logout() }) {
Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp)
}
}
}
}
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun TvEditableLoginField(
label: String,
value: String,
hint: String,
password: Boolean = false,
enabled: Boolean = true,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isFocused by remember { mutableStateOf(false) }
val visibleValue = when {
value.isBlank() -> hint
password -> "●".repeat(value.length.coerceAtLeast(1))
else -> value
}
Column(modifier = modifier) {
Text(
label,
color = if (isFocused) Color(0xFFE50914) else Color(0xFFAAAAAA),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 2.dp, bottom = 4.dp)
)
Surface(
onClick = {
if (enabled) onClick()
},
shape = RoundedCornerShape(8.dp),
color = Color(0xFF262626),
border = BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) Color(0xFFE50914) else Color(0xFF444444)
),
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable(enabled)
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
visibleValue,
color = if (value.isBlank()) Color(0xFF777777) else Color.White,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.weight(1f))
Text(
"OK",
color = if (isFocused) Color(0xFFE50914) else Color(0xFF777777),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun NativeLoginInputDialog(
title: String,
value: String,
hint: String,
password: Boolean = false,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
val context = LocalContext.current
DisposableEffect(title, value, hint, password) {
var handled = false
val editText = EditText(context).apply {
setSingleLine(true)
setText(value)
setHint(hint)
setSelection(text?.length ?: 0)
textSize = 18f
inputType = if (password) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
}
setPadding(24, 12, 24, 12)
}
val dialog = android.app.AlertDialog.Builder(context)
.setTitle(title)
.setView(editText)
.setPositiveButton("OK") { _, _ ->
handled = true
onConfirm(editText.text?.toString().orEmpty())
}
.setNegativeButton("Annuler") { _, _ ->
handled = true
onDismiss()
}
.create()
dialog.setOnDismissListener {
if (!handled) onDismiss()
}
dialog.setOnShowListener {
editText.requestFocus()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
editText.post {
editText.requestFocus()
editText.setSelection(editText.text?.length ?: 0)
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
dialog.show()
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
@Composable
private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333),
modifier = Modifier.weight(1f)
) {
Text(
label,
modifier = Modifier.padding(vertical = 10.dp),
color = if (isSelected) Color.White else Color(0xFFAAAAAA),
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
}
}
private fun Modifier.moveFocusOnDpad(
up: (() -> Unit)? = null,
down: (() -> Unit)? = null,
left: (() -> Unit)? = null,
right: (() -> Unit)? = null
): Modifier = onPreviewKeyEvent { event ->
// Important Android TV / Mi Box:
// consume DPAD on KeyDown. If KeyDown is left to the system and KeyUp
// also moves focus, one press can jump over the next field. Human input,
// naturally, needed traffic control.
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionUp -> {
if (up != null) {
up.invoke()
true
} else {
false
}
}
Key.DirectionDown -> {
if (down != null) {
down.invoke()
true
} else {
false
}
}
Key.DirectionLeft -> {
if (left != null) {
left.invoke()
true
} else {
false
}
}
Key.DirectionRight -> {
if (right != null) {
right.invoke()
true
} else {
false
}
}
else -> false
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFFE50914),
unfocusedBorderColor = Color(0xFF333333),
focusedLabelColor = Color(0xFFE50914),
unfocusedLabelColor = Color(0xFF888888),
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
@@ -0,0 +1,744 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.navigation.NavState
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
import ma.tkgraph.xtreamtv.ui.viewmodels.ContinueWatchingItem
import ma.tkgraph.xtreamtv.ui.viewmodels.MainMenuViewModel
import ma.tkgraph.xtreamtv.ui.viewmodels.RecentLiveItem
@Composable
fun MainMenuScreen(
viewModel: MainMenuViewModel = viewModel(),
onOpenTv: () -> Unit,
onOpenVod: () -> Unit,
onOpenSeries: () -> Unit,
onOpenSearch: () -> Unit,
onOpenYoutube: () -> Unit,
onNavigate: (NavState) -> Unit = {}
) {
val activity = LocalContext.current as? Activity
var showSettings by remember { mutableStateOf(false) }
var showExitDialog by remember { mutableStateOf(false) }
val tvFocusRequester = remember { FocusRequester() }
val mainScrollState = rememberScrollState()
val continueItemsState by viewModel.continueItems.collectAsStateWithLifecycle()
val recentLiveItemsState by viewModel.recentLiveItems.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
delay(180)
try { tvFocusRequester.requestFocus() } catch (_: Exception) {}
viewModel.refreshRecentLive()
viewModel.refreshContinueWatching()
}
BackHandler { showExitDialog = true }
if (showExitDialog) {
AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { Text("Quitter iMedia TV+ ?", fontWeight = FontWeight.Bold, color = DesignColors.text) },
text = { Text("Êtes-vous sûr de vouloir quitter l'application ?", color = DesignColors.muted) },
containerColor = DesignColors.bg2,
titleContentColor = DesignColors.text,
textContentColor = DesignColors.muted,
confirmButton = {
TextButton(onClick = { showExitDialog = false; activity?.finishAffinity() }) {
Text("Oui", color = DesignColors.orange)
}
},
dismissButton = {
TextButton(onClick = { showExitDialog = false }) {
Text("Non", color = DesignColors.muted)
}
}
)
}
if (showSettings) {
MainMenuSettingsDialog(
onDismiss = { showSettings = false },
onLogout = {
showSettings = false
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
XtreamApplication.instance.appContainer.authRepository.logout()
activity?.recreate()
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(DesignColors.bg3.copy(alpha = 0.45f), Color.Transparent),
radius = 1200f
)
)
.background(
Brush.radialGradient(
colors = listOf(DesignColors.blue.copy(alpha = 0.12f), Color.Transparent),
radius = 1400f,
center = androidx.compose.ui.geometry.Offset(1900f, 120f)
)
)
.background(
Brush.linearGradient(
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
)
)
) {
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 20.dp, end = 28.dp),
verticalAlignment = Alignment.CenterVertically
) {
MainMenuSettingsButton(onClick = { showSettings = true })
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(mainScrollState)
.padding(horizontal = 44.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"iMedia TV+",
style = MaterialTheme.typography.displaySmall.copy(
brush = Brush.linearGradient(
colors = listOf(DesignColors.orange, DesignColors.orange2)
)
),
fontWeight = FontWeight.Black,
letterSpacing = (-0.08).sp
)
Spacer(Modifier.height(8.dp))
Text(
"Choisissez votre espace",
color = DesignColors.muted,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
MainMenuTile(
modifier = Modifier.focusRequester(tvFocusRequester),
icon = "TV",
title = "TV",
subtitle = "Chaînes en direct",
accent = DesignColors.orange,
onClick = onOpenTv
)
Spacer(Modifier.width(14.dp))
MainMenuTile(
icon = "VOD",
title = "VOD",
subtitle = "Films à la demande",
accent = DesignColors.red,
onClick = onOpenVod
)
Spacer(Modifier.width(14.dp))
MainMenuTile(
icon = "SER",
title = "SÉRIES",
subtitle = "Saisons et épisodes",
accent = DesignColors.green,
onClick = onOpenSeries
)
Spacer(Modifier.width(14.dp))
MainMenuTile(
icon = "SRC",
title = "RECHERCHE",
subtitle = "LIVE, VOD et séries",
accent = DesignColors.blue,
onClick = onOpenSearch
)
Spacer(Modifier.width(14.dp))
MainMenuTile(
icon = "YT",
title = "YOUTUBE",
subtitle = "Vidéos enregistrées",
accent = DesignColors.red,
onClick = onOpenYoutube
)
}
Spacer(Modifier.height(26.dp))
RecentLiveSection(
itemsState = recentLiveItemsState,
onSectionFocused = {},
onPlay = { item ->
viewModel.recordRecentLive(item.stream)
onNavigate(
NavState.Player(
stream = item.stream,
allStreams = listOf(item.stream),
currentIndex = 0,
replayUrl = null,
returnState = NavState.MainMenu
)
)
}
)
Spacer(Modifier.height(18.dp))
ContinueWatchingSection(
itemsState = continueItemsState,
onSectionFocused = {},
onPlay = { item ->
onNavigate(
NavState.Player(
stream = item.stream,
allStreams = item.allStreams,
currentIndex = item.currentIndex,
replayUrl = null,
returnState = NavState.MainMenu,
progressKey = item.progressKey,
progressKeys = item.progressKeys
)
)
}
)
Spacer(Modifier.height(18.dp))
Text(
"OK pour ouvrir • BACK pour quitter",
color = DesignColors.muted2,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(42.dp))
}
}
}
@Composable
private fun RecentLiveSection(
itemsState: UiState<List<RecentLiveItem>>,
onSectionFocused: () -> Unit = {},
onPlay: (RecentLiveItem) -> Unit
) {
when (itemsState) {
is UiState.Success -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
"Dernières chaînes LIVE",
color = DesignColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 10.dp, bottom = 10.dp)
)
if (itemsState.data.isEmpty()) {
Surface(
modifier = Modifier
.width(420.dp)
.height(78.dp),
shape = RoundedCornerShape(18.dp),
color = DesignColors.panel,
border = BorderStroke(1.dp, DesignColors.stroke.copy(alpha = 0.55f))
) {
Box(
modifier = Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Text(
"Aucune chaîne récente. Lancez une chaîne LIVE, puis revenez ici.",
color = DesignColors.muted,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
return
}
LazyRow(
modifier = Modifier.fillMaxWidth().height(112.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(itemsState.data, key = { it.id }) { item ->
RecentLiveCard(
item = item,
onFocused = onSectionFocused,
onPlay = { onPlay(item) }
)
}
}
}
}
else -> return
}
}
@Composable
private fun RecentLiveCard(
item: RecentLiveItem,
onFocused: () -> Unit = {},
onPlay: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
Surface(
onClick = onPlay,
modifier = Modifier
.width(260.dp)
.height(104.dp)
.onFocusChanged {
val focused = it.isFocused || it.hasFocus
if (focused && !isFocused) onFocused()
isFocused = focused
}
.focusable()
.border(
width = if (isFocused) 3.dp else 1.dp,
color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f),
shape = RoundedCornerShape(18.dp)
),
shape = RoundedCornerShape(18.dp),
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
) {
Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(width = 76.dp, height = 82.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF151515)),
contentAlignment = Alignment.Center
) {
if (!item.imageUrl.isNullOrBlank()) {
coil3.compose.AsyncImage(
model = item.imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize()
)
} else {
Text("TV", color = DesignColors.orange, fontWeight = FontWeight.Black)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Text(
item.title,
color = DesignColors.text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(6.dp))
Text(
item.subtitle,
color = DesignColors.muted,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun ContinueWatchingSection(
itemsState: UiState<List<ContinueWatchingItem>>,
onSectionFocused: () -> Unit = {},
onPlay: (ContinueWatchingItem) -> Unit
) {
when (itemsState) {
UiState.Loading -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
"Continuer à regarder",
color = DesignColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 10.dp, bottom = 6.dp)
)
Text(
"Chargement des reprises…",
color = DesignColors.muted,
fontSize = 13.sp,
modifier = Modifier.padding(start = 10.dp)
)
}
}
is UiState.Success -> {
if (itemsState.data.isEmpty()) return
Column(modifier = Modifier.fillMaxWidth()) {
Text(
"Continuer à regarder",
color = DesignColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 10.dp, bottom = 10.dp)
)
LazyRow(
modifier = Modifier.fillMaxWidth().height(132.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(itemsState.data, key = { it.id }) { item ->
ContinueWatchingCard(
item = item,
onFocused = onSectionFocused,
onPlay = { onPlay(item) }
)
}
}
}
}
else -> return
}
}
@Composable
private fun ContinueWatchingCard(
item: ContinueWatchingItem,
onFocused: () -> Unit = {},
onPlay: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val fraction = item.progress.fraction.coerceIn(0f, 1f)
Surface(
onClick = onPlay,
modifier = Modifier
.width(310.dp)
.height(124.dp)
.onFocusChanged {
val focused = it.isFocused || it.hasFocus
if (focused && !isFocused) onFocused()
isFocused = focused
}
.focusable()
.border(
width = if (isFocused) 3.dp else 1.dp,
color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f),
shape = RoundedCornerShape(18.dp)
),
shape = RoundedCornerShape(18.dp),
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
) {
Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(width = 86.dp, height = 96.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF151515)),
contentAlignment = Alignment.Center
) {
if (!item.imageUrl.isNullOrBlank()) {
coil3.compose.AsyncImage(
model = item.imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize()
)
} else {
Text(item.title.take(2).uppercase(), color = DesignColors.orange, fontWeight = FontWeight.Black)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Text(
item.title,
color = DesignColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(5.dp))
Text(
item.subtitle,
color = DesignColors.muted,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(5.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xFF444444))
) {
if (fraction > 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction)
.background(DesignColors.orange)
)
}
}
}
}
}
}
@Composable
private fun MainMenuTile(
modifier: Modifier = Modifier,
icon: String,
title: String,
subtitle: String,
accent: Color,
onClick: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val focusAlpha by animateFloatAsState(
targetValue = if (isFocused) 1f else 0f,
animationSpec = tween(150)
)
Surface(
onClick = onClick,
modifier = modifier
.width(172.dp)
.height(158.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.border(
width = if (isFocused) 4.dp else 1.dp,
color = if (isFocused) accent.copy(alpha = focusAlpha) else DesignColors.strokeStrong.copy(alpha = 0.35f),
shape = RoundedCornerShape(26.dp)
),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = if (isFocused) {
listOf(accent.copy(alpha = 0.34f), DesignColors.panel, DesignColors.panel2)
} else {
listOf(Color(0x1FFFFFFF), DesignColors.panel, DesignColors.panel2)
}
)
)
.padding(18.dp)
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(58.dp)
.background(
Brush.linearGradient(listOf(accent, accent.copy(alpha = 0.55f))),
RoundedCornerShape(20.dp)
)
.border(
BorderStroke(1.dp, Color.White.copy(alpha = 0.22f)),
RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.Center
) {
Text(
icon,
color = Color.White,
fontSize = if (icon.length <= 2) 23.sp else 16.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(13.dp))
Text(
title,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(4.dp))
Text(
subtitle,
color = if (isFocused) DesignColors.text else DesignColors.muted,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun MainMenuSettingsButton(onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val focusAlpha by animateFloatAsState(
targetValue = if (isFocused) 1f else 0f,
animationSpec = tween(150)
)
Surface(
onClick = onClick,
modifier = Modifier
.size(42.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.border(
width = if (isFocused) 3.dp else 1.dp,
color = if (isFocused) DesignColors.orange.copy(alpha = focusAlpha) else DesignColors.stroke,
shape = RoundedCornerShape(12.dp)
),
shape = RoundedCornerShape(12.dp),
color = if (isFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF)
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("⚙", fontSize = 20.sp)
}
}
}
@Composable
private fun MainMenuSettingsDialog(onDismiss: () -> Unit, onLogout: () -> Unit) {
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Paramètres", fontWeight = FontWeight.Bold, color = DesignColors.text) },
text = {
Column(modifier = Modifier.widthIn(max = 440.dp)) {
if (config != null) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Source : ", color = DesignColors.muted, fontSize = 14.sp)
Text(
if (config.sourceType == SourceType.XTREAM) "Xtream" else "M3U",
color = DesignColors.orange,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
if (config.sourceType == SourceType.XTREAM) {
MainMenuInfoRow("Serveur", config.serverUrl)
MainMenuInfoRow("Utilisateur", config.username)
} else {
MainMenuInfoRow("URL M3U", config.m3uUrl)
}
}
Spacer(Modifier.height(12.dp))
Text(
"Appuyez sur Déconnecter pour changer de source.",
color = DesignColors.muted2,
fontSize = 12.sp
)
}
},
containerColor = DesignColors.bg2,
titleContentColor = DesignColors.text,
textContentColor = DesignColors.muted,
confirmButton = {
TextButton(onClick = { onLogout(); onDismiss() }) {
Text("Déconnecter", color = DesignColors.red)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Fermer", color = DesignColors.muted) }
}
)
}
@Composable
private fun MainMenuInfoRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text("$label : ", color = DesignColors.muted, fontSize = 13.sp)
Text(value, color = DesignColors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,553 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.view.ViewGroup
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
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.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel
import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
import timber.log.Timber
private const val VOD_SERIES_SEEK_STEP_MS = 10_000L
@Composable
fun PlayerScreen(
initialStream: LiveStreamDto,
allStreams: List<LiveStreamDto>,
initialIndex: Int,
replayUrl: String? = null,
progressKey: String? = null,
progressKeys: List<String> = emptyList(),
viewModel: PlayerViewModel = viewModel(),
onBack: () -> Unit = {}
) {
val context = LocalContext.current
val state by viewModel.state.collectAsStateWithLifecycle()
var player by remember { mutableStateOf<ExoPlayer?>(null) }
val focusRequester = remember { FocusRequester() }
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
val activeProgressKey = progressKeys.getOrNull(state.currentIndex) ?: progressKey
val isVodOrSeries = activeProgressKey != null
var currentPlayerProgressKey by remember { mutableStateOf<String?>(null) }
var youtubeVisible by remember { mutableStateOf(false) }
var isPlayerPaused by remember { mutableStateOf(false) }
var currentPositionMs by remember { mutableStateOf(0L) }
var currentDurationMs by remember { mutableStateOf(0L) }
fun saveProgressAtKey(key: String?, positionMs: Long, durationMs: Long) {
if (key == null) return
val keyIndex = progressKeys.indexOf(key)
val metadataStream = if (keyIndex >= 0) {
allStreams.getOrNull(keyIndex) ?: state.stream
} else {
state.stream
}
progressRepository.saveContinueMetadata(
key = key,
stream = metadataStream,
// Ne pas sauvegarder toute la playlist d'une série toutes les 5 secondes :
// cela ralentit les petites box TV. La reprise reprend l'épisode courant.
allStreams = emptyList(),
currentIndex = 0,
progressKeys = emptyList(),
playbackUrl = replayUrl ?: metadataStream.directUrl.takeIf { it.isNotBlank() }
)
progressRepository.saveProgress(
key = key,
positionMs = positionMs,
durationMs = normalizeDurationMs(durationMs)
)
}
fun saveProgressAt(positionMs: Long, durationMs: Long) {
saveProgressAtKey(currentPlayerProgressKey ?: activeProgressKey, positionMs, durationMs)
}
fun saveCurrentProgress() {
val currentPlayer = player ?: return
saveProgressAt(
positionMs = currentPlayer.currentPosition,
durationMs = currentPlayer.duration
)
}
LaunchedEffect(initialStream) {
viewModel.initialize(initialStream, allStreams, initialIndex)
}
// Use replay URL if provided, otherwise build live URL
val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) {
replayUrl ?: viewModel.buildUrl(state.stream)
}
DisposableEffect(Unit) {
onDispose {
saveCurrentProgress()
player?.release()
}
}
// Auto-hide overlay after 3 seconds
LaunchedEffect(state.showOverlay) {
if (state.showOverlay) {
delay(3000)
viewModel.hideOverlay()
}
}
// Update player when channel changes, with resolution listener
LaunchedEffect(streamUrl, activeProgressKey) {
if (streamUrl.isNotEmpty()) {
val oldPlayer = player
val oldProgressKey = currentPlayerProgressKey
if (oldPlayer != null) {
saveProgressAtKey(
key = oldProgressKey,
positionMs = oldPlayer.currentPosition,
durationMs = oldPlayer.duration
)
oldPlayer.stop()
oldPlayer.release()
}
val resumePositionMs = activeProgressKey?.let { progressRepository.getProgress(it) } ?: 0L
currentPlayerProgressKey = activeProgressKey
currentPositionMs = resumePositionMs
currentDurationMs = 0L
// User-Agent personnalisé pour les flux CDN/IPTV
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent("VLC/3.0.21 LibVLC/3.0.21")
.setAllowCrossProtocolRedirects(true)
val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(httpDataSourceFactory)
var firstErrorShown = false
val newPlayer = ExoPlayer.Builder(context, DefaultRenderersFactory(context))
.setMediaSourceFactory(mediaSourceFactory)
.build().apply {
volume = if (youtubeVisible) 0f else 1f
Timber.i("▶ Playing stream URL: %s", streamUrl)
setMediaItem(MediaItem.fromUri(streamUrl))
if (resumePositionMs > 0L) {
seekTo(resumePositionMs)
}
addListener(object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.width > 0 && videoSize.height > 0) {
val res = "${videoSize.width}x${videoSize.height}"
viewModel.updateResolution(res)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
currentDurationMs = normalizeDurationMs(this@apply.duration)
currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L)
if (playbackState == Player.STATE_READY) {
viewModel.resetFreezeCounter()
firstErrorShown = false
}
if (playbackState == Player.STATE_ENDED) {
val endedProgressKey = currentPlayerProgressKey ?: activeProgressKey
endedProgressKey?.let { progressRepository.markCompleted(it, currentDurationMs) }
currentPositionMs = 0L
isPlayerPaused = false
if (endedProgressKey?.startsWith("series:") == true) {
viewModel.playNextItem()
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
isPlayerPaused = !isPlaying && this@apply.playbackState != Player.STATE_ENDED
if (isPlaying) {
viewModel.resetFreezeCounter()
}
currentDurationMs = normalizeDurationMs(this@apply.duration)
currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L)
}
override fun onPlayerError(error: PlaybackException) {
if (!firstErrorShown) {
firstErrorShown = true
Timber.w("Player error on ${state.stream.name}: ${error.errorCodeName} — ${error.message}")
}
// Si le replay TV échoue, essayer le live en fallback
if (replayUrl != null && activeProgressKey == null) {
player?.stop()
player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream)))
player?.prepare()
player?.play()
}
}
})
prepare()
play()
}
player = newPlayer
// Freeze detection : attend 15s de grâce au début,
// puis ne redémarre qu'après 10s de buffering continu
val startTime = System.currentTimeMillis()
while (isActive && viewModel.canRestart()) {
delay(5000)
val elapsed = System.currentTimeMillis() - startTime
if (elapsed < 15_000) continue // grâce de 15s au démarrage
val p = player
if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
delay(5000)
if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
viewModel.requestRestart()
}
}
}
}
}
LaunchedEffect(player, youtubeVisible) {
player?.volume = if (youtubeVisible) 0f else 1f
}
LaunchedEffect(player, activeProgressKey) {
if (activeProgressKey == null) return@LaunchedEffect
while (isActive) {
val currentPlayer = player
if (currentPlayer != null) {
currentPositionMs = currentPlayer.currentPosition.coerceAtLeast(0L)
currentDurationMs = normalizeDurationMs(currentPlayer.duration)
}
delay(1000)
}
}
LaunchedEffect(player, activeProgressKey) {
if (activeProgressKey == null) return@LaunchedEffect
while (isActive) {
delay(5000)
saveCurrentProgress()
}
}
// Request focus for remote-control key events
LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }
val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp) {
if (youtubeVisible) {
// IMPORTANT : quand le mini lecteur YouTube est ouvert,
// le parent ne consomme plus aucune touche.
// Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte.
// Cela évite les différences de focus entre modèles de Mi Box.
false
} else {
when (event.key) {
Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true }
Key.DirectionLeft -> {
if (isVodOrSeries) {
player?.let { p ->
val targetPosition = (p.currentPosition - VOD_SERIES_SEEK_STEP_MS).coerceAtLeast(0L)
p.seekTo(targetPosition)
val duration = normalizeDurationMs(p.duration)
currentPositionMs = targetPosition
currentDurationMs = duration
saveProgressAt(targetPosition, duration)
}
viewModel.showOverlay()
} else {
viewModel.zappingLeft()
}
true
}
Key.DirectionRight -> {
if (isVodOrSeries) {
player?.let { p ->
val targetPosition = p.currentPosition + VOD_SERIES_SEEK_STEP_MS
val duration = normalizeDurationMs(p.duration)
val finalPosition = if (duration > 0L) targetPosition.coerceAtMost(duration) else targetPosition
p.seekTo(finalPosition)
currentPositionMs = finalPosition
currentDurationMs = duration
saveProgressAt(finalPosition, duration)
}
viewModel.showOverlay()
} else {
viewModel.zappingRight()
}
true
}
Key.DirectionCenter, Key.Enter -> {
if (isVodOrSeries) {
player?.let { p ->
if (p.isPlaying) {
p.pause()
saveCurrentProgress()
} else {
p.play()
}
currentPositionMs = p.currentPosition.coerceAtLeast(0L)
currentDurationMs = normalizeDurationMs(p.duration)
isPlayerPaused = !p.isPlaying
}
viewModel.showOverlay()
} else {
viewModel.toggleOverlay()
}
true
}
Key.Back -> {
if (state.showOverlay) {
saveCurrentProgress()
player?.stop()
player?.release()
player = null
onBack()
} else {
viewModel.toggleOverlay()
}
true
}
else -> false
}
}
} else false
}
) {
// Video player area
if (!isLoadingStream) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
useController = false
keepScreenOn = true
}
},
update = { view -> view.player = player },
modifier = Modifier.fillMaxSize()
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Text("Chargement du flux…", color = Color.Gray)
}
}
}
// Overlay bas : infos + avancement + raccourcis télécommande
AnimatedVisibility(
visible = state.showOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
) {
Surface(
color = Color.Black.copy(alpha = 0.78f),
shape = RoundedCornerShape(14.dp),
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.Top) {
// Logo
if (state.stream.streamIcon != null) {
coil3.compose.AsyncImage(
model = state.stream.streamIcon,
contentDescription = state.stream.name,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
// Nom de la chaîne / vidéo
Text(
state.stream.name,
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Résolution
if (state.resolution.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium)
}
if (isVodOrSeries && currentPositionMs > 0L) {
Spacer(Modifier.height(8.dp))
PlaybackProgressInfo(
positionMs = currentPositionMs,
durationMs = currentDurationMs,
accentColor = Color(0xFFE50914)
)
}
// Programme en cours
if (state.currentProgram.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
// Programme suivant
if (state.nextProgram.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
if (isVodOrSeries) {
Spacer(Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.14f))
)
Spacer(Modifier.height(10.dp))
if (isPlayerPaused) {
Text("Pause", color = Color.White, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Text("OK : Pause / Reprendre", color = Color.LightGray)
Text("◀ -10s", color = Color.LightGray)
Text("+10s ▶", color = Color.LightGray)
Text("Retour : menu", color = Color.LightGray)
}
} else if (allStreams.size > 1) {
Spacer(Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.14f))
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Text("▲ YouTube discret", color = Color.LightGray)
Text("◀ Chaîne précédente", color = Color.LightGray)
Text("Retour : menu", color = Color.LightGray)
Text("Chaîne suivante ▶", color = Color.LightGray)
}
}
}
}
}
if (youtubeVisible) {
YoutubeMiniPlayer(
onClose = { youtubeVisible = false },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
@Composable
private fun PlaybackProgressInfo(
positionMs: Long,
durationMs: Long,
accentColor: Color
) {
val fraction = if (durationMs > 0L) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val percent = if (fraction > 0f) (fraction * 100f).toInt().coerceIn(1, 99) else 0
Column(modifier = Modifier.fillMaxWidth()) {
Text("Avancement", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
Text(
if (durationMs > 0L && percent > 0) {
"${formatPlaybackTime(positionMs)} / ${formatPlaybackTime(durationMs)} • $percent%"
} else {
formatPlaybackTime(positionMs)
},
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(5.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xFF444444))
) {
if (fraction > 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction)
.background(accentColor)
)
}
}
}
}
private fun normalizeDurationMs(durationMs: Long): Long {
return if (durationMs > 0L && durationMs < Long.MAX_VALUE / 2L) durationMs else 0L
}
private fun formatPlaybackTime(ms: Long): String {
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}
@@ -0,0 +1,112 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun ProgramGuideScreen(
viewModel: GuideViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val streamsState by viewModel.streams.collectAsStateWithLifecycle()
val epgState by viewModel.epg.collectAsStateWithLifecycle()
val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle()
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back") }
Spacer(Modifier.width(16.dp))
Text("TV Guide", style = MaterialTheme.typography.headlineMedium)
}
when (val strs = streamsState) {
is UiState.Success -> {
val streamList = strs.data
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
items(streamList) { stream ->
Surface(
onClick = { viewModel.selectStream(stream.streamId) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cornerRadius = 10.dp
),
shape = RoundedCornerShape(10.dp),
color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(stream.name, style = MaterialTheme.typography.bodyLarge)
if (stream.streamId == selectedStreamId) {
when (val epg = epgState) {
is UiState.Success -> {
epg.data.take(5).forEach { program ->
Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall)
else -> {}
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SuggestionChip(
onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Live") }
)
SuggestionChip(
onClick = { onNavigate(NavState.ReplayGuide(stream)) },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Replay") }
)
}
}
}
}
}
}
}
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
else -> {}
}
}
}
}
@@ -0,0 +1,96 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel
@Composable
fun ReplayGuideScreen(
stream: LiveStreamDto,
viewModel: ReplayViewModel = viewModel(),
onBack: () -> Unit = {},
onPlayReplay: (String) -> Unit = {}
) {
val epgState by viewModel.epg.collectAsStateWithLifecycle()
LaunchedEffect(stream) { viewModel.loadReplay(stream) }
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back") }
Spacer(Modifier.width(16.dp))
Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium)
}
when (val epg = epgState) {
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) }
is UiState.Success -> {
val grouped = epg.data.groupBy { it.start?.take(10) ?: "" }
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
grouped.forEach { (day, programs) ->
item {
Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp))
}
items(programs) { program ->
Surface(
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cornerRadius = 10.dp
),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SuggestionChip(
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Revoir") }
)
}
}
}
}
}
}
else -> {}
}
}
}
}
@@ -0,0 +1,483 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.Color
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.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys
import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun SeriesScreen(
viewModel: SeriesViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
val seriesState by viewModel.series.collectAsStateWithLifecycle()
val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle()
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
val context = LocalContext.current
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
var selectedSeries by remember { mutableStateOf<SeriesDto?>(null) }
val seriesGridState = rememberLazyGridState()
val episodesListState = rememberLazyListState()
val categoryListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val seriesFocusRequesters = remember { mutableStateMapOf<Int, FocusRequester>() }
BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = {
if (selectedSeries != null) selectedSeries = null
else onBack()
},
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = Color(0xFF4CAF50),
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
Spacer(Modifier.width(8.dp))
Text(if (selectedSeries != null) selectedSeries!!.name else "Series",
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50))
}
// Categories (hidden in detail view)
if (selectedSeries == null) {
val catData = categoriesState
if (catData is UiState.Success) {
val catList = catData.data
LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) {
if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0)
}
LazyRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
state = categoryListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } }
item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } }
catList.forEach { cat ->
item(key = cat.categoryId) {
Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
}
}
}
}
}
// Detail view
val detailSeries = selectedSeries
if (detailSeries != null) {
val info = seriesInfoState
when {
info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) }
info is UiState.Success -> {
var lastContentRowIndex = 0
info.data.seasons.forEach { season ->
lastContentRowIndex += 1
lastContentRowIndex += info.data.episodes[season.seasonNumber.toString()].orEmpty().size
}
val maxEpisodeRowIndex = (lastContentRowIndex - 1).coerceAtLeast(0)
val playableEpisodes = info.data.seasons.flatMap { season ->
info.data.episodes[season.seasonNumber.toString()].orEmpty()
}
val episodeProgressKeys = playableEpisodes.map { ep ->
"series:${detailSeries.seriesId}:${ep.id}"
}
fun scrollToEpisodeRow(index: Int) {
val targetIndex = index.coerceIn(0, maxEpisodeRowIndex)
coroutineScope.launch {
episodesListState.animateScrollToItem(targetIndex)
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.tvFastScrollKeys(
enabled = maxEpisodeRowIndex > 0,
onJumpToTop = { scrollToEpisodeRow(0) },
onJumpToBottom = { scrollToEpisodeRow(maxEpisodeRowIndex) },
onPageUp = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex - 12) },
onPageDown = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex + 12) }
),
state = episodesListState
) {
info.data.seasons.forEach { season ->
item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) }
val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList()
items(episodes, key = { it.id }) { ep ->
val episodeProgressKey = "series:${detailSeries.seriesId}:${ep.id}"
val legacyEpisodeProgressKey = "series:${ep.id}"
val savedProgress = progressRepository.getPlaybackProgress(episodeProgressKey, legacyEpisodeProgressKey)
val progress = if (savedProgress.durationMs <= 0L) {
savedProgress.copy(durationMs = parseEpisodeDurationMs(ep.info?.duration))
} else {
savedProgress
}
Surface(onClick = {
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config != null) {
playableEpisodes.forEach { playlistEpisode ->
progressRepository.migrateProgress(
fromKey = "series:${playlistEpisode.id}",
toKey = "series:${detailSeries.seriesId}:${playlistEpisode.id}"
)
}
val urlBuilder = ma.tkgraph.xtreamtv.util.UrlBuilder(config)
val episodeStreams = playableEpisodes.map { playlistEpisode ->
ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
name = episodePlayerTitle(detailSeries.name, playlistEpisode),
streamId = playlistEpisode.id,
streamIcon = playlistEpisode.info?.movieImage ?: detailSeries.cover,
containerExtension = playlistEpisode.containerExtension,
directUrl = urlBuilder.seriesUrl(playlistEpisode.id, playlistEpisode.containerExtension)
)
}
val currentEpisodeIndex = playableEpisodes.indexOfFirst { it.id == ep.id }.coerceAtLeast(0)
val selectedStream = episodeStreams.getOrNull(currentEpisodeIndex)
if (selectedStream != null) {
onNavigate(NavState.Player(
stream = selectedStream,
allStreams = episodeStreams,
currentIndex = currentEpisodeIndex,
replayUrl = null,
returnState = NavState.Series(),
progressKey = episodeProgressKeys.getOrNull(currentEpisodeIndex) ?: episodeProgressKey,
progressKeys = episodeProgressKeys
))
}
}
}, modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
.tvFocusHighlight(
focusedBorderColor = Color(0xFF4CAF50),
cornerRadius = 10.dp
), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
when {
progress.isCompleted -> {
EpisodeCompletedBadge(accentColor = Color(0xFF4CAF50))
Spacer(Modifier.width(10.dp))
}
progress.hasProgress -> {
EpisodeProgressBadge(progress = progress, accentColor = Color(0xFF4CAF50))
Spacer(Modifier.width(10.dp))
}
}
Column(modifier = Modifier.weight(1f)) {
Text("Ep ${ep.episodeNum}: ${episodeDisplayTitle(ep)}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
if (progress.isCompleted || progress.hasProgress) {
Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFF4CAF50), maxLines = 1)
}
}
Text("\u25B6", color = Color(0xFF4CAF50))
}
}
}
}
}
}
else -> { Box(Modifier.weight(1f)) } // Idle state
}
return@Surface
}
// Grid
val s = seriesState
when {
s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) }
s is UiState.Success -> {
val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data
fun focusSeriesAt(index: Int) {
val target = seriesList.getOrNull(index) ?: return
try { seriesFocusRequesters[target.seriesId]?.requestFocus() } catch (_: Exception) {}
}
fun scrollToSeriesIndex(index: Int) {
if (seriesList.isEmpty()) return
val targetIndex = index.coerceIn(0, seriesList.lastIndex)
coroutineScope.launch {
seriesGridState.animateScrollToItem(targetIndex)
kotlinx.coroutines.delay(80)
focusSeriesAt(targetIndex)
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
state = seriesGridState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.tvFastScrollKeys(
enabled = seriesList.isNotEmpty(),
onJumpToTop = { scrollToSeriesIndex(0) },
onJumpToBottom = { scrollToSeriesIndex(seriesList.lastIndex) },
onPageUp = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex - 15) },
onPageDown = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex + 15) }
),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(seriesList, key = { it.seriesId }) { series ->
val seriesFocusRequester = remember(series.seriesId) {
seriesFocusRequesters.getOrPut(series.seriesId) { FocusRequester() }
}
val hasSeriesProgress = progressRepository.hasAnyProgressWithPrefix("series:${series.seriesId}:")
Surface(onClick = { viewModel.recordSeriesUsage(series.seriesId); selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.focusRequester(seriesFocusRequester).fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) {
var favFocused by remember { mutableStateOf(false) }
Box {
Column {
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)))
else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold)
if (hasSeriesProgress) {
SeriesInProgressBadge(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
)
}
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
}
}
IconButton(
onClick = { viewModel.toggleFavorite(series.seriesId) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.onFocusChanged { favFocused = it.isFocused }
.border(
width = if (favFocused) 2.dp else 0.dp,
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555))
}
}
}
}
}
}
else -> { Box(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val bgColor = when {
isSelected -> accentColor
isFocused -> Color(0xFF1A3A1A)
else -> Color(0xFF252525)
}
Surface(
onClick = onClick,
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 3.dp,
color = when {
isFocused -> accentColor
else -> Color.Transparent
},
shape = RoundedCornerShape(20.dp)
),
shape = RoundedCornerShape(20.dp),
color = bgColor
) {
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
}
}
@Composable
private fun SeriesInProgressBadge(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color.Black.copy(alpha = 0.74f))
.padding(horizontal = 8.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xFF4CAF50))
)
Spacer(Modifier.width(7.dp))
Text(
"En cours de visionnage",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun EpisodeCompletedBadge(
accentColor: Color
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(accentColor.copy(alpha = 0.26f))
.border(1.dp, accentColor, RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("✓", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Spacer(Modifier.width(6.dp))
Text("Déjà vu", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp, maxLines = 1)
}
}
@Composable
private fun EpisodeProgressBadge(
progress: PlaybackProgressSnapshot,
accentColor: Color
) {
Column(
modifier = Modifier
.width(74.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xFF444444))
) {
if (progress.fraction > 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progress.fraction)
.background(accentColor)
)
}
}
}
}
private fun progressLabel(progress: PlaybackProgressSnapshot): String {
if (progress.isCompleted) return "Épisode terminé"
val position = formatPlaybackTime(progress.positionMs)
return if (progress.durationMs > 0L && progress.percent > 0) {
"$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%"
} else {
"Reprise à $position"
}
}
private fun episodeDisplayTitle(ep: EpisodeDto): String {
return ep.title ?: ep.name ?: ep.episodeName ?: ""
}
private fun episodePlayerTitle(seriesName: String, ep: EpisodeDto): String {
val title = episodeDisplayTitle(ep)
return if (title.isBlank()) {
"$seriesName - Ep ${ep.episodeNum}"
} else {
"$seriesName - Ep ${ep.episodeNum}: $title"
}
}
private fun parseEpisodeDurationMs(duration: String?): Long {
if (duration.isNullOrBlank()) return 0L
val trimmed = duration.trim()
val parts = trimmed.split(":").mapNotNull { it.toLongOrNull() }
if (parts.isEmpty()) return 0L
val seconds = when (parts.size) {
3 -> parts[0] * 3600L + parts[1] * 60L + parts[2]
2 -> parts[0] * 60L + parts[1]
1 -> parts[0] * 60L
else -> 0L
}
return seconds.coerceAtLeast(0L) * 1000L
}
private fun formatPlaybackTime(ms: Long): String {
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}
@@ -0,0 +1,336 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.foundation.border
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.Color
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.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys
import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun VodScreen(
viewModel: VodViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
val moviesState by viewModel.movies.collectAsStateWithLifecycle()
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
val context = LocalContext.current
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
val gridState = rememberLazyGridState()
val categoryListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val movieFocusRequesters = remember { mutableStateMapOf<Int, FocusRequester>() }
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = Color(0xFFE50914),
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
Spacer(Modifier.width(8.dp))
Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914))
}
// Categories with ★ Favorites
val catsState = categoriesState
when (catsState) {
is UiState.Success -> {
val catList = catsState.data
LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) {
if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0)
}
LazyRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
state = categoryListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } }
item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } }
catList.forEach { cat ->
item(key = cat.categoryId) {
CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
}
}
}
}
else -> {}
}
// Movies grid with posters
when (val movies = moviesState) {
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) }
is UiState.Success -> {
val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data
fun focusMovieAt(index: Int) {
val target = movieList.getOrNull(index) ?: return
try { movieFocusRequesters[target.streamId]?.requestFocus() } catch (_: Exception) {}
}
fun scrollToMovieIndex(index: Int) {
if (movieList.isEmpty()) return
val targetIndex = index.coerceIn(0, movieList.lastIndex)
coroutineScope.launch {
gridState.animateScrollToItem(targetIndex)
kotlinx.coroutines.delay(80)
focusMovieAt(targetIndex)
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
state = gridState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.tvFastScrollKeys(
enabled = movieList.isNotEmpty(),
onJumpToTop = { scrollToMovieIndex(0) },
onJumpToBottom = { scrollToMovieIndex(movieList.lastIndex) },
onPageUp = { scrollToMovieIndex(gridState.firstVisibleItemIndex - 15) },
onPageDown = { scrollToMovieIndex(gridState.firstVisibleItemIndex + 15) }
),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(movieList, key = { it.streamId }) { movie ->
val movieFocusRequester = remember(movie.streamId) {
movieFocusRequesters.getOrPut(movie.streamId) { FocusRequester() }
}
val progress = progressRepository.getPlaybackProgress("vod:${movie.streamId}")
MovieCard(
modifier = Modifier.focusRequester(movieFocusRequester),
name = movie.name,
posterUrl = movie.streamIcon,
rating = movie.rating,
progress = progress,
isFavorite = movie.streamId in favoriteIds,
onToggleFav = { viewModel.toggleFavorite(movie.streamId) },
onPlay = {
viewModel.recordMovieUsage(movie.streamId)
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config != null) {
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension)
onNavigate(NavState.Player(
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
name = movie.name,
streamId = movie.streamId,
streamIcon = movie.streamIcon,
containerExtension = movie.containerExtension
),
allStreams = emptyList(),
currentIndex = 0,
replayUrl = url,
returnState = NavState.Vod(),
progressKey = "vod:${movie.streamId}"
))
}
}
)
}
}
}
else -> {}
}
}
}
}
@Composable
private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val bgColor = when {
isSelected -> Color(0xFFE50914)
isFocused -> Color(0xFF3A1A1A)
else -> Color(0xFF252525)
}
Surface(
onClick = onClick,
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 3.dp,
color = when {
isFocused -> Color(0xFFE50914)
else -> Color.Transparent
},
shape = RoundedCornerShape(20.dp)
),
shape = RoundedCornerShape(20.dp),
color = bgColor
) {
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
}
}
@Composable
private fun MovieCard(
modifier: Modifier = Modifier,
name: String,
posterUrl: String?,
rating: String?,
progress: PlaybackProgressSnapshot,
isFavorite: Boolean,
onToggleFav: () -> Unit,
onPlay: () -> Unit
) {
var favFocused by remember { mutableStateOf(false) }
Surface(
onClick = onPlay,
modifier = modifier
.fillMaxWidth()
.tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp),
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
tonalElevation = 2.dp
) {
Box {
Column {
// Poster
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
if (posterUrl != null) {
coil3.compose.AsyncImage(
model = posterUrl,
contentDescription = name,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
)
} else {
Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold)
}
if (progress.hasProgress) {
ProgressOverlay(
progress = progress,
accentColor = Color(0xFFE50914),
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
)
}
}
// Info bar
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA))
if (progress.hasProgress) {
Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFFE50914), maxLines = 1)
}
}
}
}
// Favorite icon in top-right corner (doesn't steal card focus)
IconButton(
onClick = onToggleFav,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.onFocusChanged { favFocused = it.isFocused }
.border(
width = if (favFocused) 2.dp else 0.dp,
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555))
}
}
}
}
@Composable
private fun ProgressOverlay(
progress: PlaybackProgressSnapshot,
accentColor: Color,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.72f))
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xFF444444))
) {
if (progress.fraction > 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progress.fraction)
.background(accentColor)
)
}
}
Spacer(Modifier.height(4.dp))
Text(progressLabel(progress), color = Color.White, fontSize = 11.sp, maxLines = 1)
}
}
private fun progressLabel(progress: PlaybackProgressSnapshot): String {
val position = formatPlaybackTime(progress.positionMs)
return if (progress.durationMs > 0L && progress.percent > 0) {
"$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%"
} else {
"Reprise à $position"
}
}
private fun formatPlaybackTime(ms: Long): String {
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}
@@ -0,0 +1,545 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
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.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.size
import androidx.compose.foundation.layout.width
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.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.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.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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
import ma.tkgraph.xtreamtv.ui.viewmodels.YoutubeViewModel
import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils
@Composable
fun YoutubeScreen(
viewModel: YoutubeViewModel = viewModel(),
onBack: () -> Unit
) {
val videos by viewModel.videos.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var titleInput by remember { mutableStateOf("") }
var linkInput by remember { mutableStateOf(YoutubeUrlUtils.DEFAULT_WATCH_PREFIX) }
var playingVideo by remember { mutableStateOf<YoutubeVideoItem?>(null) }
val linkFocusRequester = remember { FocusRequester() }
BackHandler {
if (playingVideo != null) {
playingVideo = null
} else {
onBack()
}
}
if (playingVideo != null) {
YoutubeFullPlayer(
video = playingVideo!!,
onClose = { playingVideo = null }
)
return
}
LaunchedEffect(Unit) {
runCatching { linkFocusRequester.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 42.dp, vertical = 28.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"YouTube",
style = MaterialTheme.typography.displaySmall.copy(
brush = Brush.linearGradient(listOf(DesignColors.red, DesignColors.orange2))
),
fontWeight = FontWeight.Black
)
Text(
"Ajoutez des liens ou codes YouTube, puis lancez-les depuis la TV",
color = DesignColors.muted,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
TextButton(onClick = onBack) {
Text("Retour", color = DesignColors.muted)
}
}
Spacer(Modifier.height(20.dp))
YoutubeAddPanel(
titleInput = titleInput,
onTitleChange = { titleInput = it },
linkInput = linkInput,
onLinkChange = { linkInput = it },
linkFocusRequester = linkFocusRequester,
message = message,
onAdd = {
val added = viewModel.addVideo(titleInput, linkInput)
if (added) {
titleInput = ""
linkInput = YoutubeUrlUtils.DEFAULT_WATCH_PREFIX
}
},
onClearMessage = { viewModel.clearMessage() }
)
Spacer(Modifier.height(18.dp))
Text(
"Vidéos enregistrées",
color = DesignColors.text,
fontSize = 22.sp,
fontWeight = FontWeight.Black
)
Spacer(Modifier.height(10.dp))
if (videos.isEmpty()) {
YoutubeEmptyState()
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(videos, key = { it.id }) { video ->
YoutubeVideoRow(
video = video,
onPlay = {
viewModel.markPlayed(video.id)
playingVideo = video
},
onDelete = { viewModel.deleteVideo(video.id) }
)
}
}
}
Spacer(Modifier.height(8.dp))
Text(
"OK pour lire • BACK pour revenir • Certains créateurs bloquent l'embed YouTube",
color = DesignColors.muted2,
fontSize = 12.sp
)
}
}
}
@Composable
private fun YoutubeAddPanel(
titleInput: String,
onTitleChange: (String) -> Unit,
linkInput: String,
onLinkChange: (String) -> Unit,
linkFocusRequester: FocusRequester,
message: String?,
onAdd: () -> Unit,
onClearMessage: () -> Unit
) {
Surface(
color = DesignColors.panel,
shape = RoundedCornerShape(22.dp),
border = BorderStroke(1.dp, DesignColors.strokeStrong.copy(alpha = 0.35f))
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = linkInput,
onValueChange = onLinkChange,
modifier = Modifier
.weight(1.5f)
.focusRequester(linkFocusRequester),
singleLine = true,
label = { Text("Lien ou code YouTube") },
placeholder = { Text("https://www.youtube.com/watch?v=...") }
)
OutlinedTextField(
value = titleInput,
onValueChange = onTitleChange,
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Titre optionnel") },
placeholder = { Text("Clip, documentaire...") }
)
Button(
onClick = onAdd,
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
) {
Text("Ajouter")
}
}
if (!message.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = message,
color = if (message.contains("impossible", ignoreCase = true) || message.contains("invalide", ignoreCase = true)) DesignColors.red else DesignColors.green,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onClearMessage) {
Text("OK", color = DesignColors.muted)
}
}
}
}
}
}
@Composable
private fun YoutubeEmptyState() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
.background(DesignColors.panel, RoundedCornerShape(20.dp))
.border(1.dp, DesignColors.stroke, RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Aucune vidéo YouTube enregistrée", color = DesignColors.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("Collez un lien ou juste le code vidéo, puis appuyez sur Ajouter.", color = DesignColors.muted)
}
}
}
@Composable
private fun YoutubeVideoRow(
video: YoutubeVideoItem,
onPlay: () -> Unit,
onDelete: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
Surface(
onClick = onPlay,
modifier = Modifier
.fillMaxWidth()
.height(118.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.border(
width = if (isFocused) 3.dp else 1.dp,
color = if (isFocused) DesignColors.red else DesignColors.stroke.copy(alpha = 0.55f),
shape = RoundedCornerShape(18.dp)
),
shape = RoundedCornerShape(18.dp),
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
) {
Row(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(width = 154.dp, height = 88.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.Black),
contentAlignment = Alignment.Center
) {
if (!video.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = video.thumbnailUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize()
)
} else {
Text("YT", color = DesignColors.red, fontWeight = FontWeight.Black, fontSize = 24.sp)
}
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Text(
video.title,
color = DesignColors.text,
fontSize = 19.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(5.dp))
Text(
video.url,
color = DesignColors.muted,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(5.dp))
Text(
if (video.lastPlayedAtMs > 0L) "Dernière lecture enregistrée" else "Jamais lu",
color = DesignColors.muted2,
fontSize = 12.sp
)
}
Spacer(Modifier.width(12.dp))
Button(
onClick = onPlay,
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
) {
Text("Lire")
}
Spacer(Modifier.width(8.dp))
TextButton(onClick = onDelete) {
Text("Supprimer", color = DesignColors.muted)
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun YoutubeFullPlayer(
video: YoutubeVideoItem,
onClose: () -> Unit
) {
val target = remember(video.url) { YoutubeUrlUtils.parse(video.url) }
var status by remember(video.id) { mutableStateOf("Chargement YouTube") }
var webView by remember { mutableStateOf<WebView?>(null) }
BackHandler { onClose() }
fun sendCommand(js: String) {
webView?.evaluateJavascript(js, null)
}
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
when (event.key) {
Key.Back -> {
onClose()
true
}
Key.DirectionCenter, Key.Enter -> {
sendCommand("if(window.togglePlay){togglePlay();}")
true
}
Key.DirectionLeft -> {
sendCommand("if(window.seekBy){seekBy(-10);}")
true
}
Key.DirectionRight -> {
sendCommand("if(window.seekBy){seekBy(10);}")
true
}
else -> false
}
}
.focusable()
) {
if (target == null) {
Text(
"Lien YouTube invalide",
color = DesignColors.text,
modifier = Modifier.align(Alignment.Center)
)
} else {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(false)
settings.userAgentString = YoutubeUrlUtils.DESKTOP_USER_AGENT
runCatching {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val message = consoleMessage?.message().orEmpty()
if (message.startsWith("IMEDIA_YT:")) {
status = message.removePrefix("IMEDIA_YT:")
return true
}
return super.onConsoleMessage(consoleMessage)
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean = false
override fun onPageFinished(view: WebView?, url: String?) {
if (!status.startsWith("YouTube erreur")) {
status = "Player chargé : OK Play/Pause, ←/→ -10s/+10s"
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
if (request?.isForMainFrame == true) {
status = "Erreur WebView : ${error?.description ?: "chargement impossible"}"
}
}
}
setBackgroundColor(android.graphics.Color.BLACK)
}.also { webView = it }
},
update = { view ->
webView = view
if (view.tag != video.id) {
view.tag = video.id
if (target.canUseEmbed) {
view.loadDataWithBaseURL(
YoutubeUrlUtils.WEBVIEW_ORIGIN,
YoutubeUrlUtils.buildIframeHtml(target),
"text/html",
"utf-8",
target.toEmbedUrl()
)
} else {
view.loadUrl(target.toWatchOrNormalizedUrl(), YoutubeUrlUtils.youtubeHeaders())
}
}
},
modifier = Modifier.fillMaxSize()
)
}
Surface(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(78.dp),
color = Color.Black.copy(alpha = 0.58f)
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(video.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(status, color = Color(0xFFB8C7D1), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Button(onClick = { sendCommand("if(window.seekBy){seekBy(-10);}") }) { Text("-10s") }
Spacer(Modifier.width(8.dp))
Button(onClick = { sendCommand("if(window.togglePlay){togglePlay();}") }) { Text("Play/Pause") }
Spacer(Modifier.width(8.dp))
Button(onClick = { sendCommand("if(window.seekBy){seekBy(10);}") }) { Text("+10s") }
Spacer(Modifier.width(8.dp))
Button(
onClick = onClose,
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
) { Text("Fermer") }
}
}
}
}
@@ -0,0 +1,54 @@
package ma.tkgraph.xtreamtv.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// ─── Couleurs du design iMedia TV+ ───
object DesignColors {
val bg1 = Color(0xFF020817)
val bg2 = Color(0xFF071a3d)
val bg3 = Color(0xFF2b0f53)
val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74)
val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70)
val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12)
val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42)
val text = Color(0xFFF7F8FF)
val muted = Color(0xFFA9B2D8)
val muted2 = Color(0xFF7F8AB6)
val orange = Color(0xFFFF512F)
val orange2 = Color(0xFFFFB000)
val blue = Color(0xFF1469FF)
val blue2 = Color(0xFF09B7FF)
val red = Color(0xFFFF3D64)
val green = Color(0xFF00D9B2)
val purple = Color(0xFF875CFF)
val liveRed = Color(0xFFFE3152)
val liveRedDark = Color(0xFFBA1733)
}
private val DesignColorScheme = darkColorScheme(
primary = DesignColors.orange,
onPrimary = Color.White,
primaryContainer = DesignColors.orange.copy(alpha = 0.2f),
secondary = DesignColors.blue,
onSecondary = Color.White,
surface = Color(0xBD1A1C49),
surfaceVariant = Color(0x70121D49),
onSurface = DesignColors.text,
onSurfaceVariant = DesignColors.muted,
background = DesignColors.bg1,
onBackground = DesignColors.text,
error = DesignColors.red,
onError = Color.White,
outline = DesignColors.stroke
)
@Composable
fun XtreamTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DesignColorScheme,
content = content
)
}
@@ -0,0 +1,296 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.util.UrlBuilder
class GlobalSearchViewModel : ViewModel() {
private val container = XtreamApplication.instance.appContainer
private var searchJob: Job? = null
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _scope = MutableStateFlow(SearchScope.Live)
val scope: StateFlow<SearchScope> = _scope.asStateFlow()
private val _results = MutableStateFlow<UiState<List<GlobalSearchResult>>>(UiState.Success(emptyList()))
val results: StateFlow<UiState<List<GlobalSearchResult>>> = _results.asStateFlow()
fun setQuery(value: String) {
_query.value = value
}
fun setScope(value: SearchScope) {
_scope.value = value
_results.value = UiState.Success(emptyList())
}
fun runSearch() {
val rawQuery = _query.value.trim()
if (rawQuery.normalizeForSearch().length < 2) {
_results.value = UiState.Success(emptyList())
return
}
val selectedScope = _scope.value
searchJob?.cancel()
searchJob = viewModelScope.launch(Dispatchers.IO) {
_results.value = UiState.Loading
val accumulated = mutableListOf<GlobalSearchResult>()
if (selectedScope.includesLive) {
val live = getLiveCatalog()
accumulated += filterLive(live, rawQuery)
if (accumulated.isNotEmpty()) {
_results.value = UiState.Success(accumulated.toList())
}
}
if (selectedScope.includesVod) {
val vod = getVodCatalog()
accumulated += filterVod(vod, rawQuery)
if (accumulated.isNotEmpty()) {
_results.value = UiState.Success(accumulated.toList())
}
}
if (selectedScope.includesSeries) {
val series = getSeriesCatalog()
accumulated += filterSeries(series, rawQuery)
if (accumulated.isNotEmpty()) {
_results.value = UiState.Success(accumulated.toList())
}
}
_results.value = UiState.Success(accumulated.toList())
}
}
fun recordLiveUsage(stream: LiveStreamDto) {
container.usageRepository.recordLiveStream(stream.streamId)
container.recentLiveRepository.record(stream)
}
fun recordLiveUsage(streamId: Int) {
container.usageRepository.recordLiveStream(streamId)
}
fun recordVodUsage(streamId: Int) {
container.usageRepository.recordVodItem(streamId)
}
fun recordSeriesUsage(seriesId: Int) {
container.usageRepository.recordSeriesItem(seriesId)
}
fun buildVodStream(movie: VodDto): LiveStreamDto? {
val config = container.secureStorage.getCredentials() ?: return null
val urlBuilder = UrlBuilder(config)
return LiveStreamDto(
name = movie.name,
streamId = movie.streamId,
streamIcon = movie.streamIcon,
containerExtension = movie.containerExtension,
directUrl = urlBuilder.vodUrl(movie.streamId, movie.containerExtension)
)
}
suspend fun buildSeriesLaunch(series: SeriesDto): SeriesLaunchData? {
val config = container.secureStorage.getCredentials() ?: return null
val urlBuilder = UrlBuilder(config)
val info = when (val result = container.seriesRepository.getSeriesInfo(series.seriesId)) {
is UiState.Success -> result.data
else -> return null
}
val playableEpisodes = info.seasons.flatMap { season ->
info.episodes[season.seasonNumber.toString()].orEmpty()
}
if (playableEpisodes.isEmpty()) return null
val streams = playableEpisodes.map { episode ->
LiveStreamDto(
name = episodePlayerTitle(series.name, episode),
streamId = episode.id,
streamIcon = episode.info?.movieImage ?: series.cover,
containerExtension = episode.containerExtension,
directUrl = urlBuilder.seriesUrl(episode.id, episode.containerExtension)
)
}
val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" }
return SeriesLaunchData(
stream = streams.first(),
allStreams = streams,
currentIndex = 0,
progressKey = progressKeys.firstOrNull(),
progressKeys = progressKeys
)
}
private suspend fun getLiveCatalog(): List<LiveStreamDto> {
cachedLiveStreams?.let { return it }
val loaded = when (val result = container.liveRepository.getStreams(null)) {
is UiState.Success -> result.data
else -> emptyList()
}
cachedLiveStreams = loaded
return loaded
}
private suspend fun getVodCatalog(): List<VodDto> {
cachedVodItems?.let { return it }
val loaded = when (val result = container.vodRepository.getMovies(null)) {
is UiState.Success -> result.data
else -> emptyList()
}
cachedVodItems = loaded
return loaded
}
private suspend fun getSeriesCatalog(): List<SeriesDto> {
cachedSeriesItems?.let { return it }
val loaded = when (val result = container.seriesRepository.getSeriesList(null)) {
is UiState.Success -> result.data
else -> emptyList()
}
cachedSeriesItems = loaded
return loaded
}
private fun filterLive(items: List<LiveStreamDto>, rawQuery: String): List<GlobalSearchResult> {
val normalizedQuery = rawQuery.normalizeForSearch()
return items.asSequence()
.filter { it.name.normalizeForSearch().contains(normalizedQuery) }
.take(PER_TYPE_LIMIT)
.map {
GlobalSearchResult(
id = "live:${it.streamId}",
type = GlobalSearchResultType.Live,
title = it.name,
subtitle = "LIVE • Chaîne TV",
imageUrl = it.streamIcon,
liveStream = it
)
}
.toList()
}
private fun filterVod(items: List<VodDto>, rawQuery: String): List<GlobalSearchResult> {
val normalizedQuery = rawQuery.normalizeForSearch()
return items.asSequence()
.filter { item ->
item.name.normalizeForSearch().contains(normalizedQuery) ||
item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) ||
item.year.orEmpty().contains(rawQuery)
}
.take(PER_TYPE_LIMIT)
.map {
GlobalSearchResult(
id = "vod:${it.streamId}",
type = GlobalSearchResultType.Vod,
title = it.name,
subtitle = listOfNotNull("VOD", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "),
imageUrl = it.streamIcon,
vod = it
)
}
.toList()
}
private fun filterSeries(items: List<SeriesDto>, rawQuery: String): List<GlobalSearchResult> {
val normalizedQuery = rawQuery.normalizeForSearch()
return items.asSequence()
.filter { item ->
item.name.normalizeForSearch().contains(normalizedQuery) ||
item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) ||
item.year.orEmpty().contains(rawQuery)
}
.take(PER_TYPE_LIMIT)
.map {
GlobalSearchResult(
id = "series:${it.seriesId}",
type = GlobalSearchResultType.Series,
title = it.name,
subtitle = listOfNotNull("SÉRIE", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "),
imageUrl = it.cover,
series = it
)
}
.toList()
}
companion object {
private const val PER_TYPE_LIMIT = 30
private var cachedLiveStreams: List<LiveStreamDto>? = null
private var cachedVodItems: List<VodDto>? = null
private var cachedSeriesItems: List<SeriesDto>? = null
}
}
enum class SearchScope(val label: String) {
Live("LIVE"),
Vod("VOD"),
Series("SÉRIES"),
All("ALL");
val includesLive: Boolean get() = this == Live || this == All
val includesVod: Boolean get() = this == Vod || this == All
val includesSeries: Boolean get() = this == Series || this == All
}
data class GlobalSearchResult(
val id: String,
val type: GlobalSearchResultType,
val title: String,
val subtitle: String,
val imageUrl: String?,
val liveStream: LiveStreamDto? = null,
val vod: VodDto? = null,
val series: SeriesDto? = null
)
enum class GlobalSearchResultType { Live, Vod, Series }
data class SeriesLaunchData(
val stream: LiveStreamDto,
val allStreams: List<LiveStreamDto>,
val currentIndex: Int,
val progressKey: String?,
val progressKeys: List<String>
)
private fun String.normalizeForSearch(): String = trim().lowercase()
.replace("é", "e")
.replace("è", "e")
.replace("ê", "e")
.replace("ë", "e")
.replace("à", "a")
.replace("â", "a")
.replace("î", "i")
.replace("ï", "i")
.replace("ô", "o")
.replace("ö", "o")
.replace("ù", "u")
.replace("û", "u")
.replace("ü", "u")
.replace("ç", "c")
private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: ""
private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String {
val title = episode.displayTitle()
return if (title.isBlank()) "$seriesName - Ep ${episode.episodeNum}"
else "$seriesName - Ep ${episode.episodeNum}: $title"
}
@@ -0,0 +1,44 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
class GuideViewModel : ViewModel() {
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(UiState.Success(emptyList()))
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
private val _selectedStreamId = MutableStateFlow<Int?>(null)
val selectedStreamId: StateFlow<Int?> = _selectedStreamId.asStateFlow()
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
init { loadStreams() }
fun loadStreams() {
viewModelScope.launch {
val r = liveRepo.getStreams()
if (r is UiState.Success) _streams.value = r
}
}
fun selectStream(streamId: Int) {
_selectedStreamId.value = streamId
viewModelScope.launch {
val r = epgRepo.getFullGuide(streamId)
if (r is UiState.Success) _epg.value = r
}
}
}
@@ -0,0 +1,134 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
class HomeViewModel : ViewModel() {
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository
private val _categories = MutableStateFlow<UiState<List<LiveCategoryDto>>>(
UiState.Success(emptyList())
)
val categories: StateFlow<UiState<List<LiveCategoryDto>>> = _categories.asStateFlow()
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(
UiState.Success(emptyList())
)
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
// État réactif pour les favoris et l'épinglage
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId())
val pinnedChannelId: StateFlow<Int?> = _pinnedChannelId.asStateFlow()
init {
loadCategories()
loadStreams()
}
fun loadCategories() {
viewModelScope.launch {
val result = liveRepo.getCategories()
if (result is UiState.Success) {
_categories.value = UiState.Success(sortCategories(result.data))
} else if (result is UiState.Error) {
_categories.value = UiState.Success(emptyList())
}
}
}
fun selectCategory(categoryId: Int?) {
usageRepo.recordLiveCategory(categoryId)
_selectedCategoryId.value = categoryId
refreshCategoryOrder()
if (categoryId == -1) {
// ★ Favorites : charger toutes les chaînes, filtrer dans l'UI
loadStreams(null)
} else {
loadStreams(categoryId)
}
}
fun recordChannelUsage(stream: LiveStreamDto) {
usageRepo.recordLiveStream(stream.streamId)
recentLiveRepo.record(stream)
refreshStreamOrder()
}
fun recordChannelUsage(streamId: Int) {
usageRepo.recordLiveStream(streamId)
refreshStreamOrder()
}
fun loadStreams(categoryId: Int? = null) {
viewModelScope.launch {
val result = liveRepo.getStreams(categoryId)
if (result is UiState.Success) {
_streams.value = UiState.Success(sortStreams(result.data))
} else if (result is UiState.Error) {
_streams.value = UiState.Success(emptyList())
}
}
}
fun toggleFavorite(streamId: Int) {
favRepo.toggleFavoriteLive(streamId)
_favoriteIds.value = favRepo.getFavoriteLiveIds()
}
fun togglePin(streamId: Int) {
val current = _pinnedChannelId.value
if (current == streamId) {
favRepo.setPinnedChannelId(null)
_pinnedChannelId.value = null
} else {
favRepo.setPinnedChannelId(streamId)
_pinnedChannelId.value = streamId
}
}
/** Déconnexion : efface config + cache M3U. */
fun logout() {
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
XtreamApplication.instance.appContainer.authRepository.logout()
}
private fun refreshCategoryOrder() {
val current = _categories.value
if (current is UiState.Success) {
_categories.value = UiState.Success(sortCategories(current.data))
}
}
private fun refreshStreamOrder() {
val current = _streams.value
if (current is UiState.Success) {
_streams.value = UiState.Success(sortStreams(current.data))
}
}
private fun sortCategories(categories: List<LiveCategoryDto>): List<LiveCategoryDto> =
CategorySortUtils.sortLiveCategories(categories, usageRepo.liveCategoryCounts())
private fun sortStreams(streams: List<LiveStreamDto>): List<LiveStreamDto> =
UsageSortUtils.sortByUsage(streams, usageRepo.liveStreamCounts()) { it.streamId.toString() }
}
@@ -0,0 +1,107 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
import ma.tkgraph.xtreamtv.data.repository.UserInfo
/**
* ViewModel pour l'écran de connexion / configuration.
* Gère la connexion Xtream et la configuration M3U.
*/
class LoginViewModel : ViewModel() {
private val authRepository: AuthRepository =
XtreamApplication.instance.appContainer.authRepository
private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository
private val _uiState = MutableStateFlow<UiState<UserInfo>>(UiState.Idle)
val uiState: StateFlow<UiState<UserInfo>> = _uiState.asStateFlow()
/** Type de source (Compose-reactive). */
var sourceType by mutableStateOf(SourceType.XTREAM)
var serverUrl: String = ""
private set
var username: String = ""
private set
var password: String = ""
private set
var m3uUrl: String = ""
private set
init {
// Charger la config existante
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
if (config != null) {
sourceType = config.sourceType
serverUrl = config.serverUrl
username = config.username
password = config.password
m3uUrl = config.m3uUrl
}
}
fun switchSourceType(type: SourceType) {
sourceType = type
}
fun updateServerUrl(value: String) {
serverUrl = value.trim()
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updateUsername(value: String) {
username = value.trim()
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updatePassword(value: String) {
password = value.trim()
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updateM3uUrl(value: String) {
m3uUrl = value.trim()
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
/** Connexion au serveur Xtream. */
fun login() {
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return
viewModelScope.launch {
_uiState.value = UiState.Loading
val result = authRepository.login(serverUrl, username, password)
_uiState.value = result
}
}
/** Sauvegarde la config M3U et invalide le cache. */
fun saveM3uConfig() {
if (m3uUrl.isBlank()) return
m3uRepository.invalidateCache()
authRepository.saveM3uConfig(m3uUrl)
_uiState.value = UiState.Success(UserInfo(username = "M3U"))
}
/** Déconnexion : efface la config et le cache M3U. */
fun logout() {
m3uRepository.invalidateCache()
authRepository.logout()
serverUrl = ""
username = ""
password = ""
m3uUrl = ""
sourceType = SourceType.XTREAM
_uiState.value = UiState.Idle
}
}
@@ -0,0 +1,301 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
import ma.tkgraph.xtreamtv.util.UrlBuilder
class MainMenuViewModel(application: Application) : AndroidViewModel(application) {
private val container = XtreamApplication.instance.appContainer
private val progressRepository = PlaybackProgressRepository(application)
private val recentLiveRepository = container.recentLiveRepository
private val _continueItems = MutableStateFlow<UiState<List<ContinueWatchingItem>>>(UiState.Loading)
val continueItems: StateFlow<UiState<List<ContinueWatchingItem>>> = _continueItems.asStateFlow()
private val _recentLiveItems = MutableStateFlow<UiState<List<RecentLiveItem>>>(UiState.Success(emptyList()))
val recentLiveItems: StateFlow<UiState<List<RecentLiveItem>>> = _recentLiveItems.asStateFlow()
init {
refreshRecentLive()
refreshContinueWatching()
}
fun refreshRecentLive() {
viewModelScope.launch(Dispatchers.IO) {
val items = recentLiveRepository.getRecentStreams()
.map { entry ->
RecentLiveItem(
id = "live:${entry.stream.streamId}",
title = entry.stream.name,
subtitle = "LIVE • Dernière utilisation",
imageUrl = entry.stream.streamIcon,
lastUsedMs = entry.lastUsedMs,
stream = entry.stream
)
}
_recentLiveItems.value = UiState.Success(items)
}
}
fun recordRecentLive(stream: LiveStreamDto) {
recentLiveRepository.record(stream)
refreshRecentLive()
}
/**
* Version hybride V12 :
* - affichage instantané avec les métadonnées locales quand elles existent ;
* - hydratation legacy seulement pour les anciennes progressions sans métadonnées ;
* - sauvegarde de ces métadonnées pour que les ouvertures suivantes soient rapides.
*/
fun refreshContinueWatching() {
viewModelScope.launch(Dispatchers.IO) {
val allKeys = progressRepository.getInProgressKeys()
if (allKeys.isEmpty()) {
_continueItems.value = UiState.Success(emptyList())
return@launch
}
val metadataItems = allKeys
.mapNotNull { key -> buildContinueItemFromMetadata(key) }
.sortedByDescending { it.lastWatchedMs }
val missingKeys = allKeys
.filter { key -> progressRepository.getContinueMetadata(key) == null }
.toSet()
if (metadataItems.isNotEmpty()) {
_continueItems.value = UiState.Success(metadataItems.take(MAX_CONTINUE_ITEMS))
}
if (missingKeys.isEmpty()) {
if (metadataItems.isEmpty()) _continueItems.value = UiState.Success(emptyList())
return@launch
}
// Compatibilité avec les progressions créées avant la V11/V12.
// Cette phase peut être plus lente, mais elle ne sert qu'à reconstruire les métadonnées manquantes.
val legacyItems = mutableListOf<ContinueWatchingItem>()
legacyItems += loadVodContinueItems(missingKeys)
legacyItems += loadSeriesContinueItems(missingKeys)
legacyItems.forEach { item ->
progressRepository.saveContinueMetadata(
key = item.progressKey,
stream = item.stream,
allStreams = emptyList(),
currentIndex = 0,
progressKeys = emptyList(),
playbackUrl = item.stream.directUrl.takeIf { it.isNotBlank() }
)
}
val merged = (metadataItems + legacyItems)
.distinctBy { it.id }
.sortedByDescending { it.lastWatchedMs }
.take(MAX_CONTINUE_ITEMS)
_continueItems.value = UiState.Success(merged)
}
}
private fun buildContinueItemFromMetadata(key: String): ContinueWatchingItem? {
val metadata = progressRepository.getContinueMetadata(key) ?: return null
val progress = progressRepository.getPlaybackProgress(key)
if (!progress.hasProgress) return null
val typeLabel = when {
key.startsWith("vod:") -> "Film"
key.startsWith("series:") -> "Série"
else -> "Vidéo"
}
val resolvedAllStreams = metadata.allStreams.takeIf { it.isNotEmpty() } ?: emptyList()
val resolvedIndex = metadata.currentIndex
.takeIf { it >= 0 && it < resolvedAllStreams.size }
?: 0
val stream = resolvedAllStreams.getOrNull(resolvedIndex) ?: metadata.stream
return ContinueWatchingItem(
id = key,
title = metadata.title.ifBlank { stream.name },
subtitle = "$typeLabel • ${progressText(progress)}",
imageUrl = metadata.imageUrl ?: stream.streamIcon,
progress = progress,
lastWatchedMs = progressRepository.getLastWatched(key),
stream = stream,
allStreams = resolvedAllStreams,
currentIndex = resolvedIndex,
progressKey = key,
progressKeys = metadata.progressKeys
)
}
private suspend fun loadVodContinueItems(allowedKeys: Set<String>): List<ContinueWatchingItem> {
val vodKeys = allowedKeys.filter { it.startsWith("vod:") }
if (vodKeys.isEmpty()) return emptyList()
val movies = when (val result = container.vodRepository.getMovies(null)) {
is UiState.Success -> result.data.associateBy { it.streamId }
else -> emptyMap()
}
val config = container.secureStorage.getCredentials() ?: return emptyList()
val urlBuilder = UrlBuilder(config)
return vodKeys.mapNotNull { key ->
val streamId = key.removePrefix("vod:").toIntOrNull() ?: return@mapNotNull null
val movie = movies[streamId] ?: return@mapNotNull null
val progress = progressRepository.getPlaybackProgress(key)
if (!progress.hasProgress) return@mapNotNull null
ContinueWatchingItem(
id = key,
title = movie.name,
subtitle = "Film • ${progressText(progress)}",
imageUrl = movie.streamIcon,
progress = progress,
lastWatchedMs = progressRepository.getLastWatched(key),
stream = movie.toPlayerStream(urlBuilder),
allStreams = emptyList(),
currentIndex = 0,
progressKey = key,
progressKeys = emptyList()
)
}
}
private suspend fun loadSeriesContinueItems(allowedKeys: Set<String>): List<ContinueWatchingItem> {
val seriesKeys = allowedKeys.filter { it.startsWith("series:") }
if (seriesKeys.isEmpty()) return emptyList()
val keysBySeriesId = seriesKeys.mapNotNull { key ->
val parts = key.split(":")
val seriesId = parts.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
seriesId to key
}.groupBy({ it.first }, { it.second })
val seriesById = when (val result = container.seriesRepository.getSeriesList(null)) {
is UiState.Success -> result.data.associateBy { it.seriesId }
else -> emptyMap()
}
val config = container.secureStorage.getCredentials() ?: return emptyList()
val urlBuilder = UrlBuilder(config)
return keysBySeriesId.mapNotNull { (seriesId, keys) ->
val series = seriesById[seriesId] ?: return@mapNotNull null
val info = when (val result = container.seriesRepository.getSeriesInfo(seriesId)) {
is UiState.Success -> result.data
else -> return@mapNotNull null
}
val playableEpisodes = info.seasons.flatMap { season ->
info.episodes[season.seasonNumber.toString()].orEmpty()
}
if (playableEpisodes.isEmpty()) return@mapNotNull null
val episodeStreams = playableEpisodes.map { episode -> episode.toPlayerStream(series, urlBuilder) }
val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" }
val bestKey = keys.maxByOrNull { progressRepository.getLastWatched(it) } ?: return@mapNotNull null
val index = progressKeys.indexOf(bestKey).takeIf { it >= 0 } ?: 0
val episode = playableEpisodes.getOrNull(index) ?: return@mapNotNull null
val progress = progressRepository.getPlaybackProgress(bestKey)
if (!progress.hasProgress) return@mapNotNull null
ContinueWatchingItem(
id = bestKey,
title = series.name,
subtitle = "Épisode ${episode.episodeNum} • ${episode.displayTitle().ifBlank { "Sans titre" }} • ${progressText(progress)}",
imageUrl = episode.info?.movieImage ?: series.cover,
progress = progress,
lastWatchedMs = progressRepository.getLastWatched(bestKey),
stream = episodeStreams[index],
allStreams = episodeStreams,
currentIndex = index,
progressKey = bestKey,
progressKeys = progressKeys
)
}
}
private fun VodDto.toPlayerStream(urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto(
name = name,
streamId = streamId,
streamIcon = streamIcon,
containerExtension = containerExtension,
directUrl = urlBuilder.vodUrl(streamId, containerExtension)
)
private fun EpisodeDto.toPlayerStream(series: SeriesDto, urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto(
name = episodePlayerTitle(series.name, this),
streamId = id,
streamIcon = info?.movieImage ?: series.cover,
containerExtension = containerExtension,
directUrl = urlBuilder.seriesUrl(id, containerExtension)
)
companion object {
private const val MAX_CONTINUE_ITEMS = 12
}
}
data class RecentLiveItem(
val id: String,
val title: String,
val subtitle: String,
val imageUrl: String?,
val lastUsedMs: Long,
val stream: LiveStreamDto
)
data class ContinueWatchingItem(
val id: String,
val title: String,
val subtitle: String,
val imageUrl: String?,
val progress: PlaybackProgressSnapshot,
val lastWatchedMs: Long,
val stream: LiveStreamDto,
val allStreams: List<LiveStreamDto>,
val currentIndex: Int,
val progressKey: String,
val progressKeys: List<String>
)
private fun progressText(progress: PlaybackProgressSnapshot): String {
return if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs)
}
private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: ""
private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String {
val title = episode.displayTitle()
return if (title.isBlank()) {
"$seriesName - Ep ${episode.episodeNum}"
} else {
"$seriesName - Ep ${episode.episodeNum}: $title"
}
}
private fun formatPlaybackTime(ms: Long): String {
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}
@@ -0,0 +1,184 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
import ma.tkgraph.xtreamtv.util.UrlBuilder
import timber.log.Timber
data class PlayerState(
val stream: LiveStreamDto = LiveStreamDto(),
val allStreams: List<LiveStreamDto> = emptyList(),
val currentIndex: Int = 0,
val showOverlay: Boolean = true,
val resolution: String = "",
val currentProgram: String = "",
val nextProgram: String = "",
val restartTrigger: Int = 0,
val freezeRetries: Int = 0
)
class PlayerViewModel : ViewModel() {
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository
private val _state = MutableStateFlow(PlayerState(showOverlay = true))
val state: StateFlow<PlayerState> = _state.asStateFlow()
fun initialize(stream: LiveStreamDto, allStreams: List<LiveStreamDto>, index: Int) {
_state.value = PlayerState(
stream = stream,
allStreams = allStreams,
currentIndex = index,
showOverlay = true
)
loadEpg(stream.streamId)
}
/**
* Construit l'URL de lecture selon la source configurée.
* - Mode Xtream : construit l'URL via [UrlBuilder]
* - Mode M3U : utilise [LiveStreamDto.directUrl] directement
*/
fun buildUrl(stream: LiveStreamDto): String {
// En mode M3U, utiliser l'URL directe du stream
if (stream.directUrl.isNotBlank()) {
return stream.directUrl
}
// Mode Xtream : construction via UrlBuilder
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
?: return ""
if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) {
return stream.directUrl
}
return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension)
}
fun updateResolution(resolution: String) {
_state.value = _state.value.copy(resolution = resolution)
}
private fun loadEpg(streamId: Int) {
viewModelScope.launch {
val result = epgRepo.getShortEpg(streamId)
if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) {
val programs = result.data
if (programs.isNotEmpty()) {
val now = System.currentTimeMillis() / 1000
val current = programs.firstOrNull {
val start = it.startTimestamp?.toLongOrNull()
val stop = it.stopTimestamp?.toLongOrNull()
start != null && start <= now && (stop == null || stop >= now)
} ?: programs.firstOrNull()
val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() }
val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1]
else null
_state.value = _state.value.copy(
currentProgram = current?.titleText() ?: "",
nextProgram = next?.titleText() ?: ""
)
}
}
}
}
fun zappingLeft() {
val current = _state.value
if (current.allStreams.isEmpty()) return
val newIndex = if (current.currentIndex > 0) current.currentIndex - 1
else current.allStreams.lastIndex
val newStream = current.allStreams[newIndex]
recordLiveIfNeeded(newStream)
_state.value = PlayerState(
stream = newStream,
allStreams = current.allStreams,
currentIndex = newIndex,
showOverlay = true
)
loadEpg(newStream.streamId)
}
fun zappingRight() {
val current = _state.value
if (current.allStreams.isEmpty()) return
val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1
else 0
val newStream = current.allStreams[newIndex]
recordLiveIfNeeded(newStream)
_state.value = PlayerState(
stream = newStream,
allStreams = current.allStreams,
currentIndex = newIndex,
showOverlay = true
)
loadEpg(newStream.streamId)
}
fun playNextItem(): Boolean {
val current = _state.value
if (current.allStreams.isEmpty() || current.currentIndex >= current.allStreams.lastIndex) return false
val newIndex = current.currentIndex + 1
val newStream = current.allStreams[newIndex]
_state.value = current.copy(
stream = newStream,
currentIndex = newIndex,
showOverlay = true,
resolution = "",
currentProgram = "",
nextProgram = ""
)
return true
}
fun toggleOverlay() {
_state.value = _state.value.copy(showOverlay = !_state.value.showOverlay)
}
fun hideOverlay() {
_state.value = _state.value.copy(showOverlay = false)
}
fun showOverlay() {
_state.value = _state.value.copy(showOverlay = true)
}
fun requestRestart() {
val s = _state.value
if (s.freezeRetries >= 3) return
val retries = s.freezeRetries + 1
Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3")
_state.value = s.copy(
restartTrigger = s.restartTrigger + 1,
freezeRetries = retries
)
}
fun resetFreezeCounter() {
val s = _state.value
if (s.freezeRetries > 0) {
_state.value = s.copy(freezeRetries = 0)
}
}
fun canRestart(): Boolean = _state.value.freezeRetries < 3
private fun recordLiveIfNeeded(stream: LiveStreamDto) {
if (!stream.streamType.equals("live", ignoreCase = true)) return
usageRepo.recordLiveStream(stream.streamId)
recentLiveRepo.record(stream)
}
fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId)
fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) }
}
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
class ReplayViewModel : ViewModel() {
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
private var currentStream: LiveStreamDto? = null
fun loadReplay(stream: LiveStreamDto) {
currentStream = stream
viewModelScope.launch {
_epg.value = UiState.Loading
val result = epgRepo.getFullGuide(stream.streamId)
if (result is UiState.Success) {
// Filtrer : garder uniquement les programmes passés (déjà diffusés)
val nowSeconds = System.currentTimeMillis() / 1000
val pastPrograms = result.data.filter { prog ->
val start = prog.startTimestamp?.toLongOrNull() ?: 0
start > 0 && start <= nowSeconds
}.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 }
_epg.value = UiState.Success(pastPrograms)
} else {
_epg.value = result
}
}
}
fun buildReplayUrl(program: EpgProgramDto): String? {
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
?: return null
val stream = currentStream ?: return null
val durationMinutes = program.durationMinutes()
return ma.tkgraph.xtreamtv.util.UrlBuilder(config)
.replayUrl(
streamId = stream.streamId,
start = program.start.orEmpty(),
durationMinutes = durationMinutes,
extension = "ts"
)
}
}
@@ -0,0 +1,104 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
class SeriesViewModel : ViewModel() {
private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
private val _categories = MutableStateFlow<UiState<List<SeriesCategoryDto>>>(UiState.Success(emptyList()))
val categories: StateFlow<UiState<List<SeriesCategoryDto>>> = _categories.asStateFlow()
private val _series = MutableStateFlow<UiState<List<SeriesDto>>>(UiState.Success(emptyList()))
val series: StateFlow<UiState<List<SeriesDto>>> = _series.asStateFlow()
private val _seriesInfo = MutableStateFlow<UiState<SeriesInfoDto>>(UiState.Idle)
val seriesInfo: StateFlow<UiState<SeriesInfoDto>> = _seriesInfo.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
init {
loadCategories()
loadSeries()
}
fun loadCategories() {
viewModelScope.launch {
val r = seriesRepo.getCategories()
if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data))
}
}
fun loadSeries(categoryId: Int? = null) {
viewModelScope.launch {
val r = seriesRepo.getSeriesList(categoryId)
if (r is UiState.Success) _series.value = UiState.Success(sortSeries(r.data))
}
}
fun selectCategory(categoryId: Int?) {
usageRepo.recordSeriesCategory(categoryId)
_selectedCategoryId.value = categoryId
refreshCategoryOrder()
if (categoryId == -1) {
loadSeries(null) // Load all, filter in UI
} else {
loadSeries(categoryId)
}
}
fun recordSeriesUsage(seriesId: Int) {
usageRepo.recordSeriesItem(seriesId)
refreshSeriesOrder()
}
fun loadSeriesInfo(seriesId: Int) {
viewModelScope.launch {
val r = seriesRepo.getSeriesInfo(seriesId)
if (r is UiState.Success) _seriesInfo.value = r
}
}
fun toggleFavorite(seriesId: Int) {
favRepo.toggleFavoriteSeries(seriesId)
_favoriteIds.value = favRepo.getFavoriteSeriesIds()
}
private fun refreshCategoryOrder() {
val current = _categories.value
if (current is UiState.Success) {
_categories.value = UiState.Success(sortCategories(current.data))
}
}
private fun refreshSeriesOrder() {
val current = _series.value
if (current is UiState.Success) {
_series.value = UiState.Success(sortSeries(current.data))
}
}
private fun sortCategories(categories: List<SeriesCategoryDto>): List<SeriesCategoryDto> =
CategorySortUtils.sortSeriesCategories(categories, usageRepo.seriesCategoryCounts())
private fun sortSeries(series: List<SeriesDto>): List<SeriesDto> =
UsageSortUtils.sortByUsage(series, usageRepo.seriesItemCounts()) { it.seriesId.toString() }
}
@@ -0,0 +1,93 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import ma.tkgraph.xtreamtv.util.UsageSortUtils
class VodViewModel : ViewModel() {
private val vodRepo = XtreamApplication.instance.appContainer.vodRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
private val _categories = MutableStateFlow<UiState<List<VodCategoryDto>>>(UiState.Success(emptyList()))
val categories: StateFlow<UiState<List<VodCategoryDto>>> = _categories.asStateFlow()
private val _movies = MutableStateFlow<UiState<List<VodDto>>>(UiState.Success(emptyList()))
val movies: StateFlow<UiState<List<VodDto>>> = _movies.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
init {
loadCategories()
loadMovies()
}
fun loadCategories() {
viewModelScope.launch {
val r = vodRepo.getCategories()
if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data))
}
}
fun loadMovies(categoryId: Int? = null) {
viewModelScope.launch {
val r = vodRepo.getMovies(categoryId)
if (r is UiState.Success) _movies.value = UiState.Success(sortMovies(r.data))
}
}
fun selectCategory(categoryId: Int?) {
usageRepo.recordVodCategory(categoryId)
_selectedCategoryId.value = categoryId
refreshCategoryOrder()
if (categoryId == -1) {
loadMovies(null) // Load all, filter in UI
} else {
loadMovies(categoryId)
}
}
fun recordMovieUsage(streamId: Int) {
usageRepo.recordVodItem(streamId)
refreshMovieOrder()
}
fun toggleFavorite(streamId: Int) {
favRepo.toggleFavoriteVod(streamId)
_favoriteIds.value = favRepo.getFavoriteVodIds()
}
private fun refreshCategoryOrder() {
val current = _categories.value
if (current is UiState.Success) {
_categories.value = UiState.Success(sortCategories(current.data))
}
}
private fun refreshMovieOrder() {
val current = _movies.value
if (current is UiState.Success) {
_movies.value = UiState.Success(sortMovies(current.data))
}
}
private fun sortCategories(categories: List<VodCategoryDto>): List<VodCategoryDto> =
CategorySortUtils.sortVodCategories(categories, usageRepo.vodCategoryCounts())
private fun sortMovies(movies: List<VodDto>): List<VodDto> =
UsageSortUtils.sortByUsage(movies, usageRepo.vodItemCounts()) { it.streamId.toString() }
}
@@ -0,0 +1,53 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ma.tkgraph.xtreamtv.data.repository.YoutubeRepository
import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem
class YoutubeViewModel(application: Application) : AndroidViewModel(application) {
private val repository = YoutubeRepository(application)
private val _videos = MutableStateFlow(repository.getVideos())
val videos: StateFlow<List<YoutubeVideoItem>> = _videos.asStateFlow()
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
fun addVideo(title: String, input: String): Boolean {
val result = repository.addVideo(title, input)
return result.fold(
onSuccess = {
refresh()
_message.value = "Vidéo ajoutée"
true
},
onFailure = {
_message.value = it.message ?: "Impossible d'ajouter cette vidéo"
false
}
)
}
fun deleteVideo(id: String) {
repository.deleteVideo(id)
refresh()
_message.value = "Vidéo supprimée"
}
fun markPlayed(id: String) {
repository.markPlayed(id)
refresh()
}
fun clearMessage() {
_message.value = null
}
private fun refresh() {
_videos.value = repository.getVideos()
}
}
@@ -0,0 +1,101 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
/**
* Trie les catégories :
* 1. catégories spéciales éventuelles
* 2. catégories les plus utilisées
* 3. priorités historiques du projet
* 4. ordre alphabétique
*/
object CategorySortUtils {
private val PRIORITY_KEYWORDS = listOf("bein", "beIN")
private val FRENCH_KEYWORDS = listOf(
"france", "français", "francaise", "française",
"fr", "fra", "francais", "info", "infos"
)
private val ARABIC_KEYWORDS = listOf(
"arab", "arabic", "arabe", "arabia",
"arabique", "maghreb", "moyen-orient",
"عربي", "عربية", "العربية"
)
private val SPECIAL_NAMES = listOf(
"all channels", "toutes les chaînes",
"all movies", "tous les films",
"all series", "toutes les séries",
"favorites", "favoris", "★"
)
fun sortLiveCategories(
categories: List<LiveCategoryDto>,
usageCounts: Map<String, Int> = emptyMap()
): List<LiveCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
idSelector = { it.categoryId },
usageCounts = usageCounts,
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
fun sortVodCategories(
categories: List<VodCategoryDto>,
usageCounts: Map<String, Int> = emptyMap()
): List<VodCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
idSelector = { it.categoryId },
usageCounts = usageCounts,
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
fun sortSeriesCategories(
categories: List<SeriesCategoryDto>,
usageCounts: Map<String, Int> = emptyMap()
): List<SeriesCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
idSelector = { it.categoryId },
usageCounts = usageCounts,
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
private fun <T> categoryComparator(
nameSelector: (T) -> String,
idSelector: (T) -> String,
usageCounts: Map<String, Int>,
isSpecial: (String) -> Boolean
): Comparator<T> = Comparator { a, b ->
val nameA = nameSelector(a).lowercase().trim()
val nameB = nameSelector(b).lowercase().trim()
val usageA = usageCounts[idSelector(a)] ?: 0
val usageB = usageCounts[idSelector(b)] ?: 0
when {
isSpecial(nameA) && !isSpecial(nameB) -> -1
!isSpecial(nameA) && isSpecial(nameB) -> 1
usageA != usageB -> usageB.compareTo(usageA)
isPriority(nameA) && !isPriority(nameB) -> -1
!isPriority(nameA) && isPriority(nameB) -> 1
isFrench(nameA) && !isFrench(nameB) -> -1
!isFrench(nameA) && isFrench(nameB) -> 1
isArabic(nameA) && !isArabic(nameB) -> -1
!isArabic(nameA) && isArabic(nameB) -> 1
else -> nameA.compareTo(nameB)
}
}
private fun isPriority(name: String): Boolean = PRIORITY_KEYWORDS.any { name.contains(it) }
private fun isFrench(name: String): Boolean = FRENCH_KEYWORDS.any { name.contains(it) }
private fun isArabic(name: String): Boolean =
ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' }
}
@@ -0,0 +1,14 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Vérifie l'état de la session au démarrage.
*/
class SessionManager(private val storage: SecureStorage) {
fun hasValidSession(): Boolean {
val config = storage.getConfig() ?: return false
return config.isValid
}
}
@@ -0,0 +1,53 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
class UrlBuilder(private val config: XtreamConfig) {
fun liveUrl(streamId: Int, extension: String = "m3u8"): String =
"${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension"
fun vodUrl(streamId: Int, extension: String = "mp4"): String =
"${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension"
fun seriesUrl(episodeId: Int, extension: String = "mp4"): String =
"${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension"
/**
* Construit l'URL timeshift pour le replay.
* Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts
*
* @param streamId ID de la chaîne
* @param start Date de début au format "2026-06-11 20:00"
* @param durationMinutes Durée en minutes (entre 1 et 720)
* @param extension Extension du fichier (ts par défaut)
*/
fun replayUrl(
streamId: Int,
start: String,
durationMinutes: Int = 120,
extension: String = "ts"
): String {
val safeDuration = durationMinutes.coerceIn(1, 720)
val formattedStart = formatTimeshiftStart(start)
return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
}
/**
* Formate la date pour le timeshift.
* "2026-06-11 20:00" → "2026-06-11:20-00"
*/
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 val baseUrl: String
get() = config.serverUrl.trimEnd('/')
}
@@ -0,0 +1,21 @@
package ma.tkgraph.xtreamtv.util
/**
* Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur.
* L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement.
*/
object UsageSortUtils {
fun <T> sortByUsage(
items: List<T>,
usageCounts: Map<String, Int>,
idSelector: (T) -> String
): List<T> {
if (items.size < 2 || usageCounts.isEmpty()) return items
return items.withIndex()
.sortedWith(
compareByDescending<IndexedValue<T>> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 }
.thenBy { indexed -> indexed.index }
)
.map { it.value }
}
}
@@ -0,0 +1,165 @@
package ma.tkgraph.xtreamtv.util
import android.net.Uri
/**
* Petites fonctions communes pour transformer un code/lien YouTube en cible lisible dans WebView.
* On accepte : code vidéo, youtube.com/watch?v=..., youtu.be/..., shorts, live et playlist.
*/
object YoutubeUrlUtils {
const val DEFAULT_WATCH_PREFIX = "https://www.youtube.com/watch?v="
const val WEBVIEW_ORIGIN = "https://imedia-youtube.local/"
const val DESKTOP_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private val youtubeVideoIdPattern = Regex("^[A-Za-z0-9_-]{8,20}$")
fun normalize(raw: String): String {
val value = raw.trim()
if (value.isBlank() || value == DEFAULT_WATCH_PREFIX) return ""
return when {
value.startsWith("http://") || value.startsWith("https://") -> value
value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value"
value.startsWith("watch?v=") -> "https://www.youtube.com/$value"
value.startsWith("v=") -> DEFAULT_WATCH_PREFIX + value.removePrefix("v=")
youtubeVideoIdPattern.matches(value) -> DEFAULT_WATCH_PREFIX + value
else -> "https://$value"
}
}
fun parse(raw: String): YoutubeTarget? {
val normalized = normalize(raw)
if (normalized.isBlank()) return null
return runCatching {
val uri = Uri.parse(normalized)
val host = uri.host.orEmpty().lowercase()
val pathSegments = uri.pathSegments
val videoId = when {
host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull()
uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v")
pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1]
else -> null
}?.takeIf { it.length in 8..20 }
val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() }
YoutubeTarget(
normalizedUrl = normalized,
videoId = videoId,
playlistId = playlistId,
siteUrl = normalized
)
}.getOrNull()
}
fun youtubeHeaders(): Map<String, String> = mapOf(
"Referer" to WEBVIEW_ORIGIN,
"Origin" to WEBVIEW_ORIGIN.trimEnd('/')
)
fun buildIframeHtml(target: YoutubeTarget): String {
val embedUrl = target.toEmbedUrl().escapeHtml()
return """
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="referrer" content="strict-origin-when-cross-origin">
<style>
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#ytplayer { width:100%; height:100%; background:#000; border:0; display:block; }
</style>
</head>
<body>
<iframe
id="ytplayer"
src="$embedUrl"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin">
</iframe>
<script src="https://www.youtube.com/iframe_api"></script>
<script>
var player = null;
function logImedia(message) {
try { console.log('IMEDIA_YT:' + message); } catch(e) {}
}
function onYouTubeIframeAPIReady() {
player = new YT.Player('ytplayer', {
events: {
'onReady': function(event) {
logImedia('Player prêt');
try { event.target.playVideo(); } catch(e) { logImedia('Cliquez Play si nécessaire'); }
},
'onStateChange': function(event) {
if (event.data === YT.PlayerState.PLAYING) logImedia('Lecture YouTube');
if (event.data === YT.PlayerState.PAUSED) logImedia('Pause YouTube');
if (event.data === YT.PlayerState.BUFFERING) logImedia('Chargement YouTube');
if (event.data === YT.PlayerState.ENDED) logImedia('Vidéo terminée');
},
'onError': function(event) {
logImedia('YouTube erreur ' + event.data + ' : embed refusé');
}
}
});
}
function togglePlay() {
if (!player || !player.getPlayerState) return;
var state = player.getPlayerState();
if (state === YT.PlayerState.PLAYING) {
player.pauseVideo();
} else {
player.playVideo();
}
}
function seekBy(seconds) {
if (!player || !player.getCurrentTime || !player.seekTo) return;
var current = 0;
try { current = player.getCurrentTime(); } catch(e) { current = 0; }
player.seekTo(Math.max(0, current + seconds), true);
}
</script>
</body>
</html>
""".trimIndent()
}
private fun String.escapeHtml(): String =
replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
data class YoutubeTarget(
val normalizedUrl: String,
val videoId: String?,
val playlistId: String?,
val siteUrl: String?
) {
val canUseEmbed: Boolean = videoId != null || playlistId != null
fun toEmbedUrl(): String {
val origin = Uri.encode(YoutubeUrlUtils.WEBVIEW_ORIGIN.trimEnd('/'))
val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin"
return when {
videoId != null -> "https://www.youtube.com/embed/$videoId?$common"
playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${Uri.encode(playlistId)}&$common"
else -> normalizedUrl
}
}
fun toWatchOrNormalizedUrl(): String = when {
videoId != null -> "https://www.youtube.com/watch?v=$videoId"
else -> siteUrl ?: normalizedUrl
}
fun thumbnailUrl(): String? = videoId?.let { "https://img.youtube.com/vi/$it/hqdefault.jpg" }
}