Initial upload: iptv-android-tv-2

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:57:47 +01:00
commit 5c0540e727
90 changed files with 20580 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
app/build/
+133
View File
@@ -0,0 +1,133 @@
# Xtream Google TV Player
Projet Android TV / Google TV en Kotlin pour lire un accès Xtream compatible.
Ce ZIP contient un MVP propre : écran de connexion, appel `player_api.php`, chargement des chaînes Live et lecture vidéo avec Media3 ExoPlayer.
## Contenu
```text
XtreamGoogleTvPlayer/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/libs.versions.toml
└── app/
├── build.gradle.kts
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── java/ma/tkgraph/xtreamtv/
│ ├── MainActivity.kt
│ ├── data/
│ │ ├── model/
│ │ ├── network/
│ │ └── repository/
│ ├── ui/
│ │ ├── screens/
│ │ └── theme/
│ └── util/
└── res/
```
## Fonctionnalités incluses
- Application Android TV reconnue par le launcher Google TV.
- Connexion via serveur, username et password Xtream.
- Vérification du compte via `player_api.php`.
- Récupération des chaînes Live via `get_live_streams`.
- Lecture des flux Live en `.m3u8` avec Media3 ExoPlayer.
- Navigation simple compatible télécommande.
- Dépendances prêtes dans Gradle Version Catalog.
## Prérequis
- Android Studio récent.
- JDK 17.
- SDK Android 36 installé.
- Un appareil Google TV / Android TV ou un émulateur Android TV.
## Lancement
1. Dézipper le projet.
2. Ouvrir le dossier dans Android Studio.
3. Laisser Gradle synchroniser.
4. Lancer sur un appareil Android TV / Google TV.
Commande possible :
```bash
./gradlew :app:assembleDebug
```
Sur Windows :
```powershell
gradlew.bat :app:assembleDebug
```
> Le wrapper Gradle complet n'est pas inclus dans ce ZIP. Android Studio peut utiliser son Gradle intégré. Pour générer un wrapper : `gradle wrapper`.
## Configuration Xtream attendue
Exemple :
```text
Serveur : http://exemple.com:8080
Username : mon_user
Password : mon_pass
```
L'application appelle :
```text
/player_api.php?username=USER&password=PASS
/player_api.php?username=USER&password=PASS&action=get_live_streams
```
Puis génère une URL de lecture :
```text
/live/USER/PASS/STREAM_ID.m3u8
```
Si ton fournisseur utilise `.ts` au lieu de `.m3u8`, modifie cette ligne dans `MainActivity.kt` :
```kotlin
extension = "m3u8"
```
par :
```kotlin
extension = "ts"
```
## Fichiers principaux
| Fichier | Rôle |
|---|---|
| `MainActivity.kt` | Navigation simple entre login, accueil et lecteur |
| `LoginScreen.kt` | Écran de connexion Xtream |
| `HomeScreen.kt` | Liste des chaînes Live |
| `PlayerScreen.kt` | Lecteur vidéo Media3/ExoPlayer |
| `XtreamApi.kt` | Endpoints API Xtream |
| `XtreamApiFactory.kt` | Création Retrofit dynamique selon le serveur |
| `XtreamRepository.kt` | Couche d'accès aux données |
| `XtreamUrlBuilder.kt` | Génération des URLs Live, VOD et séries |
## À améliorer ensuite
- Stockage sécurisé des identifiants.
- Favoris.
- Catégories Live.
- VOD.
- Séries.
- EPG XMLTV.
- Reprise de lecture.
- Recherche.
- Choix automatique `.m3u8` / `.ts`.
- Design TV plus avancé avec composants TV spécialisés.
## Note légale
Cette base technique sert à lire des flux pour lesquels l'utilisateur ou le fournisseur possède les droits nécessaires. Ne l'utilise pas pour contourner des accès, redistribuer du contenu protégé ou faire n'importe quoi que les avocats adorent transformer en facture.
@@ -0,0 +1,65 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "ma.tkgraph.xtreamtv"
compileSdk = 36
defaultConfig {
applicationId = "ma.tkgraph.xtreamtv"
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Dépendances TV : utiles pour faire évoluer l'interface vers des composants TV spécialisés.
implementation(libs.androidx.tv.material)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# MVP : pas de règles particulières.
# Ajouter des règles si vous activez minifyEnabled=true et si votre API utilise de la réflexion JSON avancée.
@@ -0,0 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_foreground"
android:supportsRtl="true"
android:theme="@style/Theme.XtreamTv"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,78 @@
package ma.tkgraph.xtreamtv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
import ma.tkgraph.xtreamtv.ui.theme.XtreamTvTheme
import ma.tkgraph.xtreamtv.util.XtreamUrlBuilder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
XtreamTvTheme {
XtreamTvApp()
}
}
}
}
private sealed interface AppScreen {
data object Login : AppScreen
data object Home : AppScreen
data class Player(val title: String, val url: String) : AppScreen
}
@Composable
private fun XtreamTvApp() {
var screen by remember { mutableStateOf<AppScreen>(AppScreen.Login) }
var config by remember { mutableStateOf<XtreamConfig?>(null) }
var liveStreams by remember { mutableStateOf<List<LiveStreamDto>>(emptyList()) }
when (val currentScreen = screen) {
AppScreen.Login -> LoginScreen(
onLoginSuccess = { newConfig, streams ->
config = newConfig
liveStreams = streams
screen = AppScreen.Home
}
)
AppScreen.Home -> HomeScreen(
streams = liveStreams,
onStreamSelected = { stream ->
val currentConfig = config ?: return@HomeScreen
val url = XtreamUrlBuilder.liveUrl(
config = currentConfig,
streamId = stream.streamId,
extension = "m3u8"
)
screen = AppScreen.Player(
title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
url = url
)
},
onLogout = {
config = null
liveStreams = emptyList()
screen = AppScreen.Login
}
)
is AppScreen.Player -> PlayerScreen(
title = currentScreen.title,
streamUrl = currentScreen.url,
onBack = { screen = AppScreen.Home }
)
}
}
@@ -0,0 +1,38 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class LiveStreamDto(
@SerializedName("num")
val number: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("stream_type")
val streamType: String? = null,
@SerializedName("stream_id")
val streamId: String,
@SerializedName("stream_icon")
val streamIcon: String? = null,
@SerializedName("epg_channel_id")
val epgChannelId: String? = null,
@SerializedName("added")
val added: String? = null,
@SerializedName("category_id")
val categoryId: String? = null,
@SerializedName("custom_sid")
val customSid: String? = null,
@SerializedName("tv_archive")
val tvArchive: Int? = null,
@SerializedName("direct_source")
val directSource: String? = null
)
@@ -0,0 +1,60 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class XtreamAccountResponse(
@SerializedName("user_info")
val userInfo: XtreamUserInfo? = null,
@SerializedName("server_info")
val serverInfo: XtreamServerInfo? = null
)
data class XtreamUserInfo(
@SerializedName("username")
val username: String? = null,
@SerializedName("password")
val password: String? = null,
@SerializedName("message")
val message: String? = null,
@SerializedName("auth")
val auth: String? = null,
@SerializedName("status")
val status: String? = null,
@SerializedName("exp_date")
val expirationDate: String? = null,
@SerializedName("active_cons")
val activeConnections: String? = null,
@SerializedName("max_connections")
val maxConnections: String? = null
)
data class XtreamServerInfo(
@SerializedName("url")
val url: String? = null,
@SerializedName("port")
val port: String? = null,
@SerializedName("https_port")
val httpsPort: String? = null,
@SerializedName("server_protocol")
val serverProtocol: String? = null,
@SerializedName("timezone")
val timezone: String? = null,
@SerializedName("timestamp_now")
val timestampNow: Long? = null,
@SerializedName("time_now")
val timeNow: String? = null
)
@@ -0,0 +1,7 @@
package ma.tkgraph.xtreamtv.data.model
data class XtreamConfig(
val serverUrl: String,
val username: String,
val password: String
)
@@ -0,0 +1,21 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import retrofit2.http.GET
import retrofit2.http.Query
interface XtreamApi {
@GET("player_api.php")
suspend fun getAccountInfo(
@Query("username") username: String,
@Query("password") password: String
): XtreamAccountResponse
@GET("player_api.php")
suspend fun getLiveStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_streams"
): List<LiveStreamDto>
}
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.network
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object XtreamApiFactory {
fun create(serverUrl: String): XtreamApi {
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(serverUrl))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(XtreamApi::class.java)
}
private fun normalizeBaseUrl(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/') + "/"
}
}
@@ -0,0 +1,17 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import ma.tkgraph.xtreamtv.data.network.XtreamApi
class XtreamRepository(
private val api: XtreamApi
) {
suspend fun checkAccount(username: String, password: String): XtreamAccountResponse {
return api.getAccountInfo(username = username, password = password)
}
suspend fun loadLiveStreams(username: String, password: String): List<LiveStreamDto> {
return api.getLiveStreams(username = username, password = password)
}
}
@@ -0,0 +1,152 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
@Composable
fun HomeScreen(
streams: List<LiveStreamDto>,
onStreamSelected: (LiveStreamDto) -> Unit,
onLogout: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 48.dp, vertical = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Chaînes Live",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "${streams.size} chaîne(s) chargée(s)",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
Button(onClick = onLogout) {
Text("Déconnexion")
}
}
Spacer(modifier = Modifier.height(28.dp))
if (streams.isEmpty()) {
Text(
text = "Aucune chaîne trouvée. Le serveur a peut-être répondu vide, ce qui est une façon élégante de ne pas aider.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = streams,
key = { stream -> stream.streamId }
) { stream ->
StreamRow(
stream = stream,
onClick = { onStreamSelected(stream) }
)
}
}
}
}
}
@Composable
private fun StreamRow(
stream: LiveStreamDto,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(16.dp)
Card(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.border(
width = if (focused) 3.dp else 1.dp,
color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = shape
)
.clip(shape)
.clickable(onClick = onClick)
.focusable(),
colors = CardDefaults.cardColors(
containerColor = if (focused) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surface
}
),
elevation = CardDefaults.cardElevation(defaultElevation = if (focused) 8.dp else 2.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "ID ${stream.streamId} • Catégorie ${stream.categoryId ?: "non classée"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -0,0 +1,147 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
@Composable
fun LoginScreen(
onLoginSuccess: (XtreamConfig, List<LiveStreamDto>) -> Unit
) {
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(48.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 720.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Xtream TV Player",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Connecte ton accès Xtream compatible pour charger les chaînes Live.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Serveur : http://exemple.com:8080") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = username,
onValueChange = { username = it },
label = { Text("Utilisateur") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Button(
enabled = !isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
onClick = {
scope.launch {
isLoading = true
errorMessage = null
runCatching {
val config = XtreamConfig(
serverUrl = serverUrl.trim(),
username = username.trim(),
password = password.trim()
)
val repository = XtreamRepository(XtreamApiFactory.create(config.serverUrl))
val account = repository.checkAccount(config.username, config.password)
val isAuthorized = account.userInfo?.auth == "1"
val isActive = account.userInfo?.status.equals("Active", ignoreCase = true)
if (!isAuthorized || !isActive) {
error("Compte non autorisé ou abonnement inactif.")
}
val streams = repository.loadLiveStreams(config.username, config.password)
onLoginSuccess(config, streams)
}.onFailure { throwable ->
errorMessage = throwable.message ?: "Connexion impossible au serveur."
}
isLoading = false
}
}
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(22.dp),
strokeWidth = 2.dp
)
} else {
Text("Se connecter")
}
}
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@@ -0,0 +1,103 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
@Composable
fun PlayerScreen(
title: String,
streamUrl: String,
onBack: () -> Unit
) {
BackHandler(onBack = onBack)
val context = LocalContext.current
var errorMessage by remember(streamUrl) { mutableStateOf<String?>(null) }
val player = remember(streamUrl) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(streamUrl))
prepare()
playWhenReady = true
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorMessage = "Erreur de lecture : ${error.errorCodeName}"
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
PlayerView(viewContext).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
keepScreenOn = true
}
},
update = { playerView ->
playerView.player = player
}
)
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(24.dp)
) {
Text(
text = title,
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge
)
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@@ -0,0 +1,43 @@
package ma.tkgraph.xtreamtv.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF60A5FA),
secondary = Color(0xFF93C5FD),
background = Color(0xFF050816),
surface = Color(0xFF101828),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColors = lightColorScheme(
primary = Color(0xFF2563EB),
secondary = Color(0xFF1D4ED8),
background = Color(0xFFF8FAFC),
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF111827),
onSurface = Color(0xFF111827)
)
@Composable
fun XtreamTvTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = Typography(),
content = content
)
}
@@ -0,0 +1,42 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
object XtreamUrlBuilder {
fun liveUrl(
config: XtreamConfig,
streamId: String,
extension: String = "m3u8"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/live/${config.username}/${config.password}/$streamId.$extension"
}
fun vodUrl(
config: XtreamConfig,
streamId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/movie/${config.username}/${config.password}/$streamId.$extension"
}
fun seriesUrl(
config: XtreamConfig,
episodeId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/series/${config.username}/${config.password}/$episodeId.$extension"
}
private fun normalizeServer(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/')
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#101828" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#FFFFFF" android:pathData="M42,30 l34,24 l-34,24 z" />
<path android:fillColor="#2563EB" android:pathData="M18,82 h72 v8 h-72z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#101828" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#2563EB" android:pathData="M0,120 C70,80 120,155 190,95 C240,52 280,68 320,38 L320,180 L0,180 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M122,53 l76,44 l-76,44 z" />
</vector>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Xtream TV Player</string>
</resources>
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.XtreamTv" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#4F8CFF</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
@@ -0,0 +1,32 @@
[versions]
agp = "9.0.1"
kotlin = "2.4.0"
composeBom = "2026.04.01"
activityCompose = "1.13.0"
media3 = "1.10.1"
tvMaterial = "1.1.0"
tvFoundation = "1.0.0"
retrofit = "2.11.0"
coreKtx = "1.18.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "XtreamGoogleTvPlayer"
include(":app")