Initial upload: xtream-google-tv-player
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
app/build/
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# MVP : pas de règles particulières.
|
||||
# Ajouter des règles si vous activez minifyEnabled=true et si votre API utilise de la réflexion JSON avancée.
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user