Initial upload: xtream-google-tv-player
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user