Initial upload: iptv-android-tv-hermes
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.EpgRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.LiveRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.M3uRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.RecentLiveRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.SeriesRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.UsageRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.VodRepository
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Container d'injection de dépendances manuel.
|
||||
* Pas de Hilt/Dagger pour garder le projet léger.
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
val secureStorage = SecureStorage(context)
|
||||
|
||||
val m3uRepository = M3uRepository(secureStorage)
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(XtreamBaseUrlInterceptor())
|
||||
.addInterceptor(XtreamErrorInterceptor())
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.writeTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl("http://localhost") // Sera remplacé dynamiquement
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java)
|
||||
|
||||
// ─── Repositories ───
|
||||
val authRepository = AuthRepository(xtreamApi, secureStorage)
|
||||
val usageRepository = UsageRepository(secureStorage)
|
||||
val recentLiveRepository = RecentLiveRepository(context)
|
||||
val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository, usageRepository)
|
||||
val epgRepository = EpgRepository(xtreamApi, secureStorage)
|
||||
val vodRepository = VodRepository(xtreamApi, secureStorage, usageRepository)
|
||||
val seriesRepository = SeriesRepository(xtreamApi, secureStorage, usageRepository)
|
||||
val favoritesRepository = FavoritesRepository(secureStorage)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
import ma.tkgraph.xtreamtv.ui.screens.GlobalSearchScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.MainMenuScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.VodScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.YoutubeScreen
|
||||
import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme
|
||||
import ma.tkgraph.xtreamtv.util.SessionManager
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntentExtras()
|
||||
setContent {
|
||||
XtreamTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntentExtras() {
|
||||
val serverUrl = intent.getStringExtra("server_url")
|
||||
val username = intent.getStringExtra("xtream_user")
|
||||
val password = intent.getStringExtra("xtream_pass")
|
||||
if (serverUrl != null && username != null && password != null) {
|
||||
XtreamApplication.instance.appContainer.secureStorage
|
||||
.saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation principale.
|
||||
* L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen
|
||||
* qui vérifie le pin au chargement des streams.
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val sessionManager = remember {
|
||||
SessionManager(XtreamApplication.instance.appContainer.secureStorage)
|
||||
}
|
||||
var hasSession by remember {
|
||||
mutableStateOf(sessionManager.hasValidSession())
|
||||
}
|
||||
var navState by remember { mutableStateOf<NavState>(NavState.MainMenu) }
|
||||
var hasAutoPlayedPin by remember { mutableStateOf(false) }
|
||||
|
||||
if (!hasSession) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
hasSession = true
|
||||
navState = NavState.MainMenu
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = navState) {
|
||||
is NavState.MainMenu -> MainMenuScreen(
|
||||
onOpenTv = { navState = NavState.Home() },
|
||||
onOpenVod = { navState = NavState.Vod() },
|
||||
onOpenSeries = { navState = NavState.Series() },
|
||||
onOpenSearch = { navState = NavState.GlobalSearch },
|
||||
onOpenYoutube = { navState = NavState.Youtube },
|
||||
onNavigate = { navState = it }
|
||||
)
|
||||
|
||||
is NavState.GlobalSearch -> GlobalSearchScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Youtube -> YoutubeScreen(
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Home -> HomeScreen(
|
||||
focusStreamId = state.focusStreamId,
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu },
|
||||
hasAutoPlayedPin = hasAutoPlayedPin,
|
||||
onAutoPlayPin = { hasAutoPlayedPin = true }
|
||||
)
|
||||
|
||||
is NavState.Player -> PlayerScreen(
|
||||
initialStream = state.stream,
|
||||
allStreams = state.allStreams,
|
||||
initialIndex = state.currentIndex,
|
||||
replayUrl = state.replayUrl,
|
||||
progressKey = state.progressKey,
|
||||
progressKeys = state.progressKeys,
|
||||
onBack = {
|
||||
navState = state.returnState ?: NavState.Home()
|
||||
}
|
||||
)
|
||||
is NavState.ProgramGuide -> ProgramGuideScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.ReplayGuide -> ReplayGuideScreen(
|
||||
stream = state.stream,
|
||||
onBack = { navState = NavState.Home(state.stream.streamId) },
|
||||
onPlayReplay = { url ->
|
||||
navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url)
|
||||
}
|
||||
)
|
||||
|
||||
is NavState.Vod -> VodScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Series -> SeriesScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.SeriesDetail -> SeriesScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.app.Application
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Point d'entrée de l'application iMedia TV+.
|
||||
* Configure le logging et l'injection de dépendances.
|
||||
*/
|
||||
class XtreamApplication : Application() {
|
||||
|
||||
/** Container d'injection de dépendances (manual DI). */
|
||||
lateinit var appContainer: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
appContainer = AppContainer(this)
|
||||
initLogging()
|
||||
}
|
||||
|
||||
private fun initLogging() {
|
||||
if (BuildConfig.ENABLE_LOGGING) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
// En production : Crashlytics tree (à configurer)
|
||||
// Timber.plant(CrashReportingTree())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: XtreamApplication
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Réponse de l'API /player_api.php?username=...&password=...
|
||||
*/
|
||||
data class AuthResponse(
|
||||
@SerializedName("user_info") val userInfo: UserInfo? = null,
|
||||
val auth: String = "0",
|
||||
val status: String = ""
|
||||
)
|
||||
|
||||
data class UserInfo(
|
||||
val username: String = "",
|
||||
@SerializedName("auth") val auth: Int = 0,
|
||||
val status: String = "",
|
||||
@SerializedName("created_at") val createdAt: String = "",
|
||||
@SerializedName("exp_date") val expDate: String = "",
|
||||
@SerializedName("is_trial") val isTrial: String = "0",
|
||||
@SerializedName("max_connections") val maxConnections: String = "0",
|
||||
@SerializedName("allowed_output_formats") val allowedFormats: List<String> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import android.util.Base64
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
data class EpgProgramDto(
|
||||
@SerializedName("id") val id: String? = null,
|
||||
@SerializedName("epg_id") val epgId: String? = null,
|
||||
@SerializedName("title") val rawTitle: String? = null,
|
||||
@SerializedName("description") val rawDescription: String? = null,
|
||||
@SerializedName("start") val start: String? = null,
|
||||
@SerializedName("end") val end: String? = null,
|
||||
@SerializedName("start_timestamp") val startTimestamp: String? = null,
|
||||
@SerializedName("stop_timestamp") val stopTimestamp: String? = null,
|
||||
@SerializedName("has_archive") val hasArchive: Int? = null,
|
||||
@SerializedName("now_playing") val nowPlaying: Int? = null,
|
||||
@SerializedName("lang") val lang: String? = null
|
||||
) {
|
||||
fun titleText(): String {
|
||||
return decodeMaybeBase64(rawTitle).ifBlank { "" }
|
||||
}
|
||||
|
||||
fun startMillis(): Long? {
|
||||
startTimestamp?.toLongOrNull()?.let { return it * 1000L }
|
||||
return null
|
||||
}
|
||||
|
||||
fun stopMillis(): Long? {
|
||||
stopTimestamp?.toLongOrNull()?.let { return it * 1000L }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */
|
||||
fun durationMinutes(): Int {
|
||||
// Depuis les timestamps Unix (en secondes)
|
||||
val startSec = startTimestamp?.toLongOrNull() ?: return 120
|
||||
val stopSec = stopTimestamp?.toLongOrNull() ?: return 120
|
||||
return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720)
|
||||
}
|
||||
}
|
||||
|
||||
/** Décode si Base64, sinon retourne tel quel. */
|
||||
private fun decodeMaybeBase64(value: String?): String {
|
||||
val text = value.orEmpty().trim()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
val decoded = Base64.decode(text, Base64.DEFAULT)
|
||||
String(decoded, StandardCharsets.UTF_8).trim()
|
||||
}.getOrNull()
|
||||
?.takeIf { decoded ->
|
||||
decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() }
|
||||
}
|
||||
?: text
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class EpgShortResponse(
|
||||
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
|
||||
data class EpgFullResponse(
|
||||
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour une catégorie Live.
|
||||
*/
|
||||
data class LiveCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour un flux Live (chaîne TV).
|
||||
*/
|
||||
data class LiveStreamDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("stream_id") val streamId: Int = 0,
|
||||
@SerializedName("stream_type") val streamType: String = "",
|
||||
@SerializedName("stream_icon") val streamIcon: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("epg_channel_id") val epgChannelId: String? = null,
|
||||
@SerializedName("tv_archive") val tvArchive: Int = 0,
|
||||
@SerializedName("has_archive") val hasArchive: Boolean = false,
|
||||
@SerializedName("container_extension") val containerExtension: String = "m3u8",
|
||||
val added: String = "",
|
||||
@SerializedName("custom_sid") val customSid: String? = null,
|
||||
@SerializedName("tv_archive_duration") val archiveDuration: Int = 0,
|
||||
/** URL directe du flux (utilisé en mode M3U). */
|
||||
val directUrl: String = ""
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour une série.
|
||||
*/
|
||||
data class SeriesDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("series_id") val seriesId: Int = 0,
|
||||
val cover: String? = null,
|
||||
val plot: String? = null,
|
||||
val cast: String? = null,
|
||||
val rating: String? = null,
|
||||
val genre: String? = null,
|
||||
val year: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("last_modified") val lastModified: String? = null
|
||||
)
|
||||
|
||||
data class SeriesCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Détail d'une série (saisons + épisodes).
|
||||
*/
|
||||
data class SeriesInfoDto(
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val episodes: Map<String, List<EpisodeDto>> = emptyMap()
|
||||
)
|
||||
|
||||
data class SeasonDto(
|
||||
val id: Int = 0,
|
||||
@SerializedName("season_number") val seasonNumber: Int = 0,
|
||||
val name: String = ""
|
||||
)
|
||||
|
||||
data class EpisodeDto(
|
||||
val id: Int = 0,
|
||||
@SerializedName("episode_num") val episodeNum: Int = 0,
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
@SerializedName("episode_name") val episodeName: String? = null,
|
||||
@SerializedName("container_extension") val containerExtension: String = "mp4",
|
||||
val info: EpisodeInfoDto? = null
|
||||
)
|
||||
|
||||
data class EpisodeInfoDto(
|
||||
@SerializedName("movie_image") val movieImage: String? = null,
|
||||
val plot: String? = null,
|
||||
val duration: String? = null,
|
||||
val rating: String? = null
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
/**
|
||||
* État générique pour tous les écrans.
|
||||
* Usage : ViewModel expose `StateFlow<UiState<T>>`
|
||||
*/
|
||||
sealed interface UiState<out T> {
|
||||
data object Idle : UiState<Nothing>
|
||||
data object Loading : UiState<Nothing>
|
||||
data class Success<T>(val data: T) : UiState<T>
|
||||
data class Error(
|
||||
val message: String,
|
||||
val retry: Boolean = true,
|
||||
val exception: XtreamException? = null
|
||||
) : UiState<Nothing>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour un film VOD.
|
||||
*/
|
||||
data class VodDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("stream_id") val streamId: Int = 0,
|
||||
@SerializedName("stream_type") val streamType: String = "movie",
|
||||
@SerializedName("stream_icon") val streamIcon: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("container_extension") val containerExtension: String = "mp4",
|
||||
val rating: String? = null,
|
||||
val added: String = "",
|
||||
val description: String? = null,
|
||||
val year: String? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: String? = null
|
||||
)
|
||||
|
||||
data class VodCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
/**
|
||||
* Type de source : serveur Xtream ou fichier M3U.
|
||||
*/
|
||||
enum class SourceType { XTREAM, M3U }
|
||||
|
||||
/**
|
||||
* Configuration de connexion (Xtream ou M3U).
|
||||
*/
|
||||
data class XtreamConfig(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val m3uUrl: String = "",
|
||||
val sourceType: SourceType = SourceType.XTREAM
|
||||
) {
|
||||
/** Vrai si une configuration valide est présente (quel que soit le mode). */
|
||||
val isValid: Boolean get() = when (sourceType) {
|
||||
SourceType.XTREAM -> serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
SourceType.M3U -> m3uUrl.isNotBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Hiérarchie d'exceptions Xtream.
|
||||
* Étend IOException pour être compatible avec OkHttp Interceptor.
|
||||
*/
|
||||
sealed class XtreamException(
|
||||
message: String,
|
||||
cause: Throwable? = null
|
||||
) : IOException(message, cause) {
|
||||
|
||||
class NetworkException(message: String) : XtreamException(message)
|
||||
|
||||
class TimeoutException(message: String) : XtreamException(message)
|
||||
|
||||
class AuthException(message: String) : XtreamException(message)
|
||||
|
||||
class ServerException(val code: Int, message: String) : XtreamException(message)
|
||||
|
||||
class ParseException(message: String, cause: Throwable) : XtreamException(message, cause)
|
||||
|
||||
class StreamUnavailableException(message: String) : XtreamException(message)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package ma.tkgraph.xtreamtv.data.network
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.AuthResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgFullResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgShortResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Interface Retrofit pour l'API Xtream Codes.
|
||||
* L'URL de base est définie dynamiquement par le Repository.
|
||||
*/
|
||||
interface XtreamApi {
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun authenticate(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String
|
||||
): AuthResponse
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getLiveCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_live_categories"
|
||||
): List<LiveCategoryDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getLiveStreams(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_live_streams",
|
||||
@Query("category_id") categoryId: Int? = null
|
||||
): List<LiveStreamDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getShortEpg(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_short_epg",
|
||||
@Query("stream_id") streamId: String,
|
||||
@Query("limit") limit: Int = 8
|
||||
): EpgShortResponse
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getFullEpg(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_simple_data_table",
|
||||
@Query("stream_id") streamId: String
|
||||
): EpgFullResponse
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getVodCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_vod_categories"
|
||||
): List<VodCategoryDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getVodStreams(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_vod_streams",
|
||||
@Query("category_id") categoryId: Int? = null
|
||||
): List<VodDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getSeriesCategories(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series_categories"
|
||||
): List<SeriesCategoryDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getSeries(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series",
|
||||
@Query("category_id") categoryId: Int? = null
|
||||
): List<SeriesDto>
|
||||
|
||||
@GET("/player_api.php")
|
||||
suspend fun getSeriesInfo(
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("action") action: String = "get_series_info",
|
||||
@Query("series_id") seriesId: Int
|
||||
): SeriesInfoDto
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ma.tkgraph.xtreamtv.data.network
|
||||
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Intercepteur OkHttp qui réécrit l'URL de base de Retrofit
|
||||
* avec l'URL du serveur Xtream stockée dans SecureStorage.
|
||||
*
|
||||
* Retrofit est initialisé avec "http://localhost" comme placeholder.
|
||||
* Cet interceptor remplace "localhost" par le vrai serveur Xtream.
|
||||
*/
|
||||
class XtreamBaseUrlInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
|
||||
if (config == null || config.serverUrl.isBlank()) {
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
val serverUrl = normalize(config.serverUrl)
|
||||
val newUrl = originalRequest.url.newBuilder()
|
||||
.scheme(extractScheme(serverUrl))
|
||||
.host(extractHost(serverUrl))
|
||||
.port(extractPort(serverUrl))
|
||||
.build()
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private fun normalize(raw: String): String {
|
||||
var value = raw.trim()
|
||||
.replace("\\s".toRegex(), "")
|
||||
.replace("\\u00A0", "")
|
||||
|
||||
if (!value.startsWith("http://", ignoreCase = true) &&
|
||||
!value.startsWith("https://", ignoreCase = true)
|
||||
) {
|
||||
value = "http://$value"
|
||||
}
|
||||
|
||||
val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true)
|
||||
if (playerApiIndex >= 0) {
|
||||
value = value.substring(0, playerApiIndex)
|
||||
}
|
||||
|
||||
return value.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun extractScheme(url: String): String =
|
||||
if (url.startsWith("https://", ignoreCase = true)) "https" else "http"
|
||||
|
||||
private fun extractHost(url: String): String {
|
||||
return url
|
||||
.removePrefix("http://")
|
||||
.removePrefix("https://")
|
||||
.substringBefore("/")
|
||||
.substringBefore(":")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun extractPort(url: String): Int {
|
||||
val withoutProtocol = url
|
||||
.removePrefix("http://")
|
||||
.removePrefix("https://")
|
||||
.substringBefore("/")
|
||||
|
||||
val explicitPort = withoutProtocol.substringAfter(":", missingDelimiterValue = "")
|
||||
.toIntOrNull()
|
||||
|
||||
return explicitPort ?: if (extractScheme(url) == "https") 443 else 80
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ma.tkgraph.xtreamtv.data.network
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
/**
|
||||
* Intercepteur OkHttp qui transforme les erreurs réseau
|
||||
* en exceptions typées pour un traitement uniforme.
|
||||
*/
|
||||
class XtreamErrorInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
|
||||
when {
|
||||
response.code == 401 || response.code == 403 ->
|
||||
throw XtreamException.AuthException("Authentication failed")
|
||||
response.code in 400..499 ->
|
||||
throw XtreamException.ServerException(
|
||||
response.code, "Client error: ${response.code}"
|
||||
)
|
||||
response.code in 500..599 ->
|
||||
throw XtreamException.ServerException(
|
||||
response.code, "Server error: ${response.code}"
|
||||
)
|
||||
}
|
||||
|
||||
response
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Timber.w("Network timeout: ${request.url}")
|
||||
throw XtreamException.TimeoutException("Connection timed out")
|
||||
} catch (e: ConnectException) {
|
||||
Timber.w("Connection refused: ${request.url}")
|
||||
throw XtreamException.NetworkException("Cannot connect to server")
|
||||
} catch (e: UnknownHostException) {
|
||||
Timber.w("DNS resolution failed for: ${request.url}")
|
||||
throw XtreamException.NetworkException("DNS resolution failed")
|
||||
} catch (e: XtreamException) {
|
||||
// Déjà typé, on relance tel quel
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamException
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Gère l'authentification et la session utilisateur.
|
||||
*/
|
||||
class AuthRepository(
|
||||
private val api: XtreamApi,
|
||||
private val storage: SecureStorage
|
||||
) {
|
||||
|
||||
/**
|
||||
* Tente de se connecter au serveur Xtream.
|
||||
*
|
||||
* Le premier login doit sauvegarder temporairement la config avant l'appel API
|
||||
* parce que XtreamBaseUrlInterceptor lit l'URL serveur depuis SecureStorage.
|
||||
*/
|
||||
suspend fun login(serverUrl: String, username: String, password: String): UiState<UserInfo> {
|
||||
val cleanServerUrl = normalizeServerUrl(serverUrl)
|
||||
val cleanUsername = username.trim()
|
||||
val cleanPassword = password.trim()
|
||||
val previousConfig = storage.getConfig()
|
||||
val temporaryConfig = XtreamConfig(
|
||||
sourceType = SourceType.XTREAM,
|
||||
serverUrl = cleanServerUrl,
|
||||
username = cleanUsername,
|
||||
password = cleanPassword
|
||||
)
|
||||
|
||||
return try {
|
||||
Timber.i("Attempting login for user: %s on %s", cleanUsername, cleanServerUrl)
|
||||
|
||||
storage.saveConfig(temporaryConfig)
|
||||
|
||||
val response = api.authenticate(cleanUsername, cleanPassword)
|
||||
|
||||
// Les serveurs Xtream retournent généralement :
|
||||
// { "user_info": { "auth": 1, "status": "Active", ... }, "server_info": ... }
|
||||
// L'ancien code lisait response.auth au niveau racine, qui est souvent absent.
|
||||
// Résultat : faux "identifiants invalides". Merci le format sauvage.
|
||||
val authValue = response.userInfo?.auth?.toString()
|
||||
?: response.auth
|
||||
|
||||
val statusValue = response.userInfo?.status
|
||||
?: response.status
|
||||
|
||||
val isAuthenticated = authValue == "1" || authValue.equals("true", ignoreCase = true)
|
||||
val normalizedStatus = statusValue.trim()
|
||||
|
||||
when {
|
||||
!isAuthenticated -> {
|
||||
Timber.w("Login failed: auth=%s status=%s", authValue, normalizedStatus)
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error("Identifiants invalides ou format serveur non reconnu.", retry = true)
|
||||
}
|
||||
|
||||
normalizedStatus.isNotBlank() && !normalizedStatus.equals("Active", ignoreCase = true) -> {
|
||||
Timber.w("Login failed: account status is %s", normalizedStatus)
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error(
|
||||
"Compte non actif. Statut : $normalizedStatus",
|
||||
retry = false
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
storage.saveConfig(temporaryConfig)
|
||||
val info = UserInfo(
|
||||
username = response.userInfo?.username?.takeIf { it.isNotBlank() } ?: cleanUsername,
|
||||
status = normalizedStatus.ifBlank { "Active" },
|
||||
expDate = response.userInfo?.expDate?.takeIf { it.isNotBlank() },
|
||||
maxConnections = response.userInfo?.maxConnections?.takeIf { it.isNotBlank() } ?: "1",
|
||||
isTrial = response.userInfo?.isTrial == "1"
|
||||
)
|
||||
Timber.i("Login successful for: %s", cleanUsername)
|
||||
UiState.Success(info)
|
||||
}
|
||||
}
|
||||
} catch (e: XtreamException.TimeoutException) {
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error("Le serveur ne répond pas. Vérifiez l'URL ou réessayez.", retry = true, exception = e)
|
||||
} catch (e: XtreamException.NetworkException) {
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error("Impossible de joindre le serveur. Vérifiez l'URL, le port et le réseau.", retry = true, exception = e)
|
||||
} catch (e: XtreamException.AuthException) {
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error("Identifiants refusés par le serveur.", retry = true, exception = e)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unexpected error during login")
|
||||
restorePreviousConfig(previousConfig)
|
||||
UiState.Error("Connexion échouée : ${e.message ?: "erreur inconnue"}", retry = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePreviousConfig(previousConfig: XtreamConfig?) {
|
||||
if (previousConfig != null) {
|
||||
storage.saveConfig(previousConfig)
|
||||
} else {
|
||||
storage.clearConfigPreservingLocalData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeServerUrl(raw: String): String {
|
||||
var value = raw.trim()
|
||||
.replace("\\s".toRegex(), "")
|
||||
.replace("\\u00A0", "")
|
||||
|
||||
if (value.isBlank()) return value
|
||||
|
||||
if (!value.startsWith("http://", ignoreCase = true) &&
|
||||
!value.startsWith("https://", ignoreCase = true)
|
||||
) {
|
||||
value = "http://$value"
|
||||
}
|
||||
|
||||
val playerApiIndex = value.indexOf("/player_api.php", ignoreCase = true)
|
||||
if (playerApiIndex >= 0) {
|
||||
value = value.substring(0, playerApiIndex)
|
||||
}
|
||||
|
||||
return value.trimEnd('/')
|
||||
}
|
||||
|
||||
/** Sauvegarde une config M3U. */
|
||||
fun saveM3uConfig(m3uUrl: String) {
|
||||
storage.saveConfig(XtreamConfig(
|
||||
sourceType = SourceType.M3U,
|
||||
m3uUrl = m3uUrl.trim()
|
||||
))
|
||||
}
|
||||
|
||||
/** Supprime la session et vide le cache M3U. */
|
||||
fun logout() {
|
||||
storage.clearAll()
|
||||
Timber.i("User logged out")
|
||||
}
|
||||
|
||||
fun hasSession(): Boolean = storage.hasConfig()
|
||||
fun getSavedConfig(): XtreamConfig? = storage.getConfig()
|
||||
}
|
||||
|
||||
/** Informations utilisateur retournées après connexion. */
|
||||
data class UserInfo(
|
||||
val username: String,
|
||||
val status: String = "Active",
|
||||
val expDate: String? = null,
|
||||
val maxConnections: String = "1",
|
||||
val isTrial: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import timber.log.Timber
|
||||
|
||||
class EpgRepository(
|
||||
private val api: XtreamApi,
|
||||
private val storage: SecureStorage
|
||||
) {
|
||||
private fun config(): XtreamConfig? = storage.getCredentials()
|
||||
|
||||
suspend fun getShortEpg(streamId: Int): UiState<List<EpgProgramDto>> {
|
||||
return try {
|
||||
val c = config() ?: return UiState.Error("Not logged in")
|
||||
val response = api.getShortEpg(c.username, c.password, streamId = streamId.toString(), limit = 8)
|
||||
val programs = response.listings.orEmpty()
|
||||
.map { it.copy(rawTitle = it.rawTitle) } // titleText() sera appelé côté UI
|
||||
Timber.i("EPG short %d: %d programs", streamId, programs.size)
|
||||
UiState.Success(programs)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "EPG short failed stream=%d", streamId)
|
||||
UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFullGuide(streamId: Int): UiState<List<EpgProgramDto>> {
|
||||
return try {
|
||||
val c = config() ?: return UiState.Error("Not logged in")
|
||||
val response = api.getFullEpg(c.username, c.password, streamId = streamId.toString())
|
||||
val programs = response.listings.orEmpty()
|
||||
UiState.Success(programs)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "EPG full failed stream=%d", streamId)
|
||||
UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamException
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Helper pour enrober les appels réseau dans un bloc try-catch
|
||||
* standardisé qui retourne UiState.Error pour toute exception.
|
||||
*/
|
||||
suspend fun <T> catchErrors(block: suspend () -> UiState<T>): UiState<T> {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: XtreamException.TimeoutException) {
|
||||
UiState.Error("Server is not responding. Try again later.", retry = true, exception = e)
|
||||
} catch (e: XtreamException.NetworkException) {
|
||||
UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e)
|
||||
} catch (e: XtreamException.AuthException) {
|
||||
UiState.Error("Authentication failed.", retry = true, exception = e)
|
||||
} catch (e: XtreamException.ServerException) {
|
||||
UiState.Error("Server error (${e.code}). Contact your provider.", retry = true, exception = e)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unexpected error")
|
||||
UiState.Error("An unexpected error occurred.", retry = true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
|
||||
/**
|
||||
* Gère les favoris pour Live, VOD et Séries.
|
||||
* Délègue le stockage à SecureStorage.
|
||||
*/
|
||||
class FavoritesRepository(private val storage: SecureStorage) {
|
||||
|
||||
// ─── Live Favorites ───
|
||||
fun getFavoriteLiveIds(): Set<Int> = storage.getFavoriteLiveIds()
|
||||
|
||||
fun toggleFavoriteLive(streamId: Int): Boolean {
|
||||
val current = getFavoriteLiveIds().toMutableSet()
|
||||
return if (streamId in current) {
|
||||
current.remove(streamId)
|
||||
storage.setFavoriteLiveIds(current)
|
||||
false // removed
|
||||
} else {
|
||||
current.add(streamId)
|
||||
storage.setFavoriteLiveIds(current)
|
||||
true // added
|
||||
}
|
||||
}
|
||||
|
||||
fun isFavoriteLive(streamId: Int): Boolean = streamId in getFavoriteLiveIds()
|
||||
|
||||
// ─── VOD Favorites ───
|
||||
fun getFavoriteVodIds(): Set<Int> = storage.getFavoriteVodIds()
|
||||
|
||||
fun toggleFavoriteVod(streamId: Int): Boolean {
|
||||
val current = getFavoriteVodIds().toMutableSet()
|
||||
return if (streamId in current) {
|
||||
current.remove(streamId)
|
||||
storage.setFavoriteVodIds(current)
|
||||
false
|
||||
} else {
|
||||
current.add(streamId)
|
||||
storage.setFavoriteVodIds(current)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Series Favorites ───
|
||||
fun getFavoriteSeriesIds(): Set<Int> = storage.getFavoriteSeriesIds()
|
||||
|
||||
fun toggleFavoriteSeries(seriesId: Int): Boolean {
|
||||
val current = getFavoriteSeriesIds().toMutableSet()
|
||||
return if (seriesId in current) {
|
||||
current.remove(seriesId)
|
||||
storage.setFavoriteSeriesIds(current)
|
||||
false
|
||||
} else {
|
||||
current.add(seriesId)
|
||||
storage.setFavoriteSeriesIds(current)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pinned Channel ───
|
||||
fun getPinnedChannelId(): Int? = storage.getPinnedChannelId()
|
||||
|
||||
fun setPinnedChannelId(streamId: Int?) = storage.setPinnedChannelId(streamId)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Repository pour les flux Live TV.
|
||||
* Délègue vers XtreamApi ou M3uParser selon la source configurée.
|
||||
*/
|
||||
class LiveRepository(
|
||||
private val api: XtreamApi,
|
||||
private val storage: SecureStorage,
|
||||
private val m3uRepository: M3uRepository,
|
||||
private val usageRepository: UsageRepository
|
||||
) {
|
||||
private fun requireConfig(): XtreamConfig? = storage.getConfig()
|
||||
|
||||
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
|
||||
val config = requireConfig() ?: return UiState.Error("Not logged in")
|
||||
return when (config.sourceType) {
|
||||
SourceType.XTREAM -> getXtreamCategories(config)
|
||||
SourceType.M3U -> when (val result = m3uRepository.getCategories()) {
|
||||
is UiState.Success -> UiState.Success(
|
||||
CategorySortUtils.sortLiveCategories(result.data, usageRepository.liveCategoryCounts())
|
||||
)
|
||||
else -> result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
|
||||
val config = requireConfig() ?: return UiState.Error("Not logged in")
|
||||
return when (config.sourceType) {
|
||||
SourceType.XTREAM -> getXtreamStreams(config, categoryId)
|
||||
SourceType.M3U -> when (val result = m3uRepository.getStreams(categoryId)) {
|
||||
is UiState.Success -> UiState.Success(
|
||||
UsageSortUtils.sortByUsage(result.data, usageRepository.liveStreamCounts()) { it.streamId.toString() }
|
||||
)
|
||||
else -> result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getXtreamCategories(config: XtreamConfig): UiState<List<LiveCategoryDto>> {
|
||||
return try {
|
||||
val categories = api.getLiveCategories(config.username, config.password)
|
||||
val sorted = CategorySortUtils.sortLiveCategories(categories, usageRepository.liveCategoryCounts())
|
||||
UiState.Success(sorted)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch Xtream categories")
|
||||
UiState.Error("Erreur de chargement des catégories")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getXtreamStreams(config: XtreamConfig, categoryId: Int?): UiState<List<LiveStreamDto>> {
|
||||
return try {
|
||||
val streams = api.getLiveStreams(config.username, config.password, categoryId = categoryId)
|
||||
val sorted = UsageSortUtils.sortByUsage(streams, usageRepository.liveStreamCounts()) { it.streamId.toString() }
|
||||
UiState.Success(sorted)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch Xtream streams")
|
||||
UiState.Error("Erreur de chargement des chaînes")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Parse un fichier M3U (Xtream-compatible) en listes de [LiveStreamDto].
|
||||
*
|
||||
* Format supporté :
|
||||
* #EXTM3U
|
||||
* #EXTINF:-1 tvg-id="…" tvg-name="…" tvg-logo="…" group-title="Catégorie",Nom de la chaîne
|
||||
* http://url-du-flux.m3u8
|
||||
*
|
||||
* Chaque chaîne reçoit un streamId = hash de son URL (pour les favoris/pin).
|
||||
*/
|
||||
object M3uParser {
|
||||
|
||||
private const val TIMEOUT_SECONDS = 15L
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", "VLC/3.0.21 LibVLC/3.0.21")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Télécharge et parse une playlist M3U.
|
||||
* @param url URL distante ou locale du fichier M3U
|
||||
* @return Pair (catégories, streams) ou null si échec
|
||||
*/
|
||||
suspend fun fetchAndParse(url: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
|
||||
return try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
Timber.w("M3U fetch failed: HTTP ${response.code}")
|
||||
return null
|
||||
}
|
||||
val body = response.body?.string() ?: return null
|
||||
parse(body)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "M3U fetch error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le contenu textuel d'un fichier M3U.
|
||||
*/
|
||||
fun parse(content: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>> {
|
||||
val categoryMap = mutableMapOf<String, Int>() // nom → id (hash)
|
||||
val categories = mutableListOf<LiveCategoryDto>()
|
||||
val streams = mutableListOf<LiveStreamDto>()
|
||||
|
||||
val lines = content.lines()
|
||||
var i = 0
|
||||
var streamIdCounter = 1
|
||||
|
||||
while (i < lines.size) {
|
||||
val line = lines[i].trim()
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
// Extraire les attributs de la ligne EXTINF
|
||||
val attrs = parseExtInfAttributes(line)
|
||||
|
||||
// L'URL est sur la ligne suivante (si elle existe et ne commence pas par #)
|
||||
val urlLine = if (i + 1 < lines.size) lines[i + 1].trim() else ""
|
||||
val streamUrl = if (urlLine.isNotBlank() && !urlLine.startsWith("#")) urlLine else ""
|
||||
|
||||
if (streamUrl.isNotBlank()) {
|
||||
val name = attrs["name"] ?: "Chaîne $streamIdCounter"
|
||||
val logo = attrs["logo"]
|
||||
val groupTitle = attrs["group-title"]
|
||||
|
||||
// Générer un ID stable à partir de l'URL
|
||||
val stableId = streamUrl.hashCode()
|
||||
// Fallback si hash trop petit/négatif
|
||||
val streamId = if (stableId > 0) stableId else streamIdCounter
|
||||
|
||||
// Gérer les catégories
|
||||
if (groupTitle != null && groupTitle !in categoryMap) {
|
||||
val catId = categoryMap.size + 1
|
||||
categoryMap[groupTitle] = catId
|
||||
categories.add(LiveCategoryDto(
|
||||
categoryId = catId.toString(),
|
||||
categoryName = groupTitle
|
||||
))
|
||||
}
|
||||
|
||||
val catIdStr = if (groupTitle != null) categoryMap[groupTitle]?.toString() ?: "" else ""
|
||||
|
||||
streams.add(LiveStreamDto(
|
||||
num = streamIdCounter,
|
||||
name = name,
|
||||
streamId = streamId,
|
||||
streamType = "live",
|
||||
streamIcon = logo,
|
||||
categoryId = catIdStr,
|
||||
containerExtension = streamUrl.substringAfterLast('.', "m3u8"),
|
||||
directUrl = streamUrl
|
||||
))
|
||||
streamIdCounter++
|
||||
i++ // Sauter la ligne URL
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
Timber.i("M3U parsed: ${categories.size} categories, ${streams.size} streams")
|
||||
return Pair(categories, streams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les attributs d'une ligne #EXTINF.
|
||||
* Exemple : #EXTINF:-1 tvg-logo="https://…" group-title="Sports",Nom
|
||||
*/
|
||||
private fun parseExtInfAttributes(line: String): Map<String, String> {
|
||||
val attrs = mutableMapOf<String, String>()
|
||||
|
||||
// Extraire les paires clé="valeur"
|
||||
val regex = Regex("""(\w[\w-]*)\s*=\s*"([^"]*)"""")
|
||||
for (match in regex.findAll(line)) {
|
||||
attrs[match.groupValues[1]] = match.groupValues[2]
|
||||
}
|
||||
|
||||
// Extraire le nom de la chaîne (après la dernière virgule)
|
||||
val commaIdx = line.lastIndexOf(',')
|
||||
if (commaIdx >= 0 && commaIdx + 1 < line.length) {
|
||||
val name = line.substring(commaIdx + 1).trim()
|
||||
if (name.isNotBlank()) {
|
||||
attrs["name"] = name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback sur tvg-name si le nom n'a pas été trouvé après la virgule
|
||||
if (!attrs.containsKey("name") && attrs.containsKey("tvg-name")) {
|
||||
attrs["name"] = attrs["tvg-name"]!!
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Repository pour les playlists M3U.
|
||||
*/
|
||||
class M3uRepository(private val storage: SecureStorage) {
|
||||
|
||||
private var cachedCategories: List<LiveCategoryDto> = emptyList()
|
||||
private var cachedStreams: List<LiveStreamDto> = emptyList()
|
||||
private val fetchMutex = Mutex()
|
||||
|
||||
/** Récupère les catégories depuis le M3U. */
|
||||
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
|
||||
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
|
||||
return UiState.Success(data.first)
|
||||
}
|
||||
|
||||
/** Récupère les streams depuis le M3U. */
|
||||
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
|
||||
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
|
||||
val streams = if (categoryId != null) {
|
||||
data.second.filter { it.categoryId == categoryId.toString() }
|
||||
} else {
|
||||
data.second
|
||||
}
|
||||
return UiState.Success(streams)
|
||||
}
|
||||
|
||||
/** Invalide le cache M3U. */
|
||||
fun invalidateCache() {
|
||||
cachedCategories = emptyList()
|
||||
cachedStreams = emptyList()
|
||||
}
|
||||
|
||||
private suspend fun fetchIfNeeded(): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
|
||||
if (cachedStreams.isNotEmpty()) {
|
||||
return Pair(cachedCategories, cachedStreams)
|
||||
}
|
||||
|
||||
return fetchMutex.withLock {
|
||||
if (cachedStreams.isNotEmpty()) {
|
||||
return@withLock Pair(cachedCategories, cachedStreams)
|
||||
}
|
||||
|
||||
val config = storage.getConfig() ?: return@withLock null
|
||||
if (config.m3uUrl.isBlank()) return@withLock null
|
||||
|
||||
Timber.i("Fetching M3U from: ${config.m3uUrl}")
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
M3uParser.fetchAndParse(config.m3uUrl)
|
||||
} ?: return@withLock null
|
||||
|
||||
cachedCategories = result.first
|
||||
cachedStreams = result.second
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Sauvegarde locale de la progression des films VOD et des épisodes de séries.
|
||||
*
|
||||
* Les clés sont volontairement simples : "vod:<streamId>" ou "series:<seriesId>:<episodeId>".
|
||||
* Cela évite de mélanger deux vidéos, même si elles ont le même titre.
|
||||
*/
|
||||
class PlaybackProgressRepository(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getProgress(key: String): Long = prefs.getLong(key, 0L)
|
||||
|
||||
fun getPlaybackProgress(key: String): PlaybackProgressSnapshot {
|
||||
val positionMs = prefs.getLong(key, 0L)
|
||||
val durationMs = prefs.getLong(durationKey(key), 0L)
|
||||
val completed = prefs.getBoolean(completedKey(key), false)
|
||||
return PlaybackProgressSnapshot(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
isCompleted = completed
|
||||
)
|
||||
}
|
||||
|
||||
fun saveProgress(key: String, positionMs: Long, durationMs: Long) {
|
||||
if (positionMs < MIN_SAVED_POSITION_MS) {
|
||||
clearProgress(key)
|
||||
return
|
||||
}
|
||||
|
||||
val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs
|
||||
val shouldMarkCompleted = normalizedDurationMs > 0L &&
|
||||
(normalizedDurationMs - positionMs <= FINISHED_THRESHOLD_MS || positionMs >= (normalizedDurationMs * FINISHED_RATIO).toLong())
|
||||
|
||||
if (shouldMarkCompleted) {
|
||||
markCompleted(key, normalizedDurationMs)
|
||||
} else {
|
||||
prefs.edit()
|
||||
.putLong(key, positionMs)
|
||||
.putLong(lastWatchedKey(key), System.currentTimeMillis())
|
||||
.remove(completedKey(key))
|
||||
.apply {
|
||||
if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs)
|
||||
}
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stocke les métadonnées nécessaires pour afficher "Continuer à regarder"
|
||||
* sans recharger tout le catalogue VOD/Séries.
|
||||
*/
|
||||
fun saveContinueMetadata(
|
||||
key: String,
|
||||
stream: LiveStreamDto,
|
||||
allStreams: List<LiveStreamDto> = emptyList(),
|
||||
currentIndex: Int = 0,
|
||||
progressKeys: List<String> = emptyList(),
|
||||
playbackUrl: String? = null
|
||||
) {
|
||||
val resolvedDirectUrl = playbackUrl?.takeIf { it.isNotBlank() } ?: stream.directUrl
|
||||
val payload = JSONObject().apply {
|
||||
put("title", stream.name)
|
||||
put("imageUrl", stream.streamIcon.orEmpty())
|
||||
put("streamId", stream.streamId)
|
||||
put("containerExtension", stream.containerExtension)
|
||||
put("directUrl", resolvedDirectUrl)
|
||||
put("currentIndex", currentIndex)
|
||||
put("allStreams", JSONArray().apply {
|
||||
allStreams.forEach { item -> put(item.toJson()) }
|
||||
})
|
||||
put("progressKeys", JSONArray().apply {
|
||||
progressKeys.forEach { item -> put(item) }
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(metadataKey(key), payload.toString()).commit()
|
||||
}
|
||||
|
||||
fun getContinueMetadata(key: String): ContinueMetadata? {
|
||||
val raw = prefs.getString(metadataKey(key), null) ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val allStreamsJson = json.optJSONArray("allStreams") ?: JSONArray()
|
||||
val allStreams = buildList {
|
||||
for (i in 0 until allStreamsJson.length()) {
|
||||
allStreamsJson.optJSONObject(i)?.toLiveStreamDto()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
val progressKeysJson = json.optJSONArray("progressKeys") ?: JSONArray()
|
||||
val progressKeys = buildList {
|
||||
for (i in 0 until progressKeysJson.length()) {
|
||||
add(progressKeysJson.optString(i))
|
||||
}
|
||||
}.filter { it.isNotBlank() }
|
||||
val fallbackStream = LiveStreamDto(
|
||||
name = json.optString("title"),
|
||||
streamId = json.optInt("streamId"),
|
||||
streamIcon = json.optString("imageUrl").takeIf { it.isNotBlank() },
|
||||
containerExtension = json.optString("containerExtension", "mp4"),
|
||||
directUrl = json.optString("directUrl").takeIf { it.isNotBlank() } ?: ""
|
||||
)
|
||||
ContinueMetadata(
|
||||
title = json.optString("title"),
|
||||
imageUrl = json.optString("imageUrl").takeIf { it.isNotBlank() },
|
||||
stream = allStreams.getOrNull(json.optInt("currentIndex", 0)) ?: fallbackStream,
|
||||
allStreams = allStreams,
|
||||
currentIndex = json.optInt("currentIndex", 0).coerceAtLeast(0),
|
||||
progressKeys = progressKeys
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun markCompleted(key: String, durationMs: Long = 0L) {
|
||||
val normalizedDurationMs = durationMs.takeIf { it > 0L } ?: getPlaybackProgress(key).durationMs
|
||||
prefs.edit()
|
||||
.remove(key)
|
||||
.remove(lastWatchedKey(key))
|
||||
.remove(metadataKey(key))
|
||||
.putBoolean(completedKey(key), true)
|
||||
.apply {
|
||||
if (normalizedDurationMs > 0L) putLong(durationKey(key), normalizedDurationMs)
|
||||
}
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun hasAnyProgressWithPrefix(prefix: String): Boolean {
|
||||
return getInProgressKeys(prefix).isNotEmpty()
|
||||
}
|
||||
|
||||
fun getInProgressKeys(prefix: String? = null): List<String> {
|
||||
return prefs.all.keys
|
||||
.asSequence()
|
||||
.filter { key -> prefix == null || key.startsWith(prefix) }
|
||||
.filterNot { key -> key.startsWith(METADATA_PREFIX) }
|
||||
.filterNot { key -> key.endsWith(DURATION_SUFFIX) || key.endsWith(COMPLETED_SUFFIX) || key.endsWith(LAST_WATCHED_SUFFIX) }
|
||||
.filter { key -> prefs.getLong(key, 0L) >= MIN_SAVED_POSITION_MS }
|
||||
.filterNot { key -> prefs.getBoolean(completedKey(key), false) }
|
||||
.sortedWith(
|
||||
compareByDescending<String> { key -> getLastWatched(key) }
|
||||
.thenBy { key -> key }
|
||||
)
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getLastWatched(key: String): Long = prefs.getLong(lastWatchedKey(key), 0L)
|
||||
|
||||
fun getPlaybackProgress(primaryKey: String, fallbackKey: String): PlaybackProgressSnapshot {
|
||||
val primary = getPlaybackProgress(primaryKey)
|
||||
return if (primary.hasProgress || primary.isCompleted) primary else getPlaybackProgress(fallbackKey)
|
||||
}
|
||||
|
||||
fun migrateProgress(fromKey: String, toKey: String) {
|
||||
if (fromKey == toKey) return
|
||||
val existingProgress = getPlaybackProgress(toKey)
|
||||
if (existingProgress.hasProgress || existingProgress.isCompleted) return
|
||||
|
||||
val oldProgress = getPlaybackProgress(fromKey)
|
||||
if (!oldProgress.hasProgress && !oldProgress.isCompleted) return
|
||||
|
||||
val oldLastWatched = getLastWatched(fromKey)
|
||||
val oldMetadata = prefs.getString(metadataKey(fromKey), null)
|
||||
val editor = prefs.edit()
|
||||
.remove(fromKey)
|
||||
.remove(durationKey(fromKey))
|
||||
.remove(completedKey(fromKey))
|
||||
.remove(lastWatchedKey(fromKey))
|
||||
.remove(metadataKey(fromKey))
|
||||
|
||||
if (oldProgress.isCompleted) {
|
||||
editor.putBoolean(completedKey(toKey), true)
|
||||
} else {
|
||||
editor.putLong(toKey, oldProgress.positionMs)
|
||||
if (oldLastWatched > 0L) editor.putLong(lastWatchedKey(toKey), oldLastWatched)
|
||||
if (oldMetadata != null) editor.putString(metadataKey(toKey), oldMetadata)
|
||||
}
|
||||
if (oldProgress.durationMs > 0L) {
|
||||
editor.putLong(durationKey(toKey), oldProgress.durationMs)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
fun clearProgress(key: String) {
|
||||
prefs.edit()
|
||||
.remove(key)
|
||||
.remove(durationKey(key))
|
||||
.remove(lastWatchedKey(key))
|
||||
.remove(metadataKey(key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun durationKey(key: String): String = "$key$DURATION_SUFFIX"
|
||||
private fun completedKey(key: String): String = "$key$COMPLETED_SUFFIX"
|
||||
private fun lastWatchedKey(key: String): String = "$key$LAST_WATCHED_SUFFIX"
|
||||
private fun metadataKey(key: String): String = "$METADATA_PREFIX$key"
|
||||
|
||||
private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply {
|
||||
put("name", name)
|
||||
put("streamId", streamId)
|
||||
put("streamIcon", streamIcon.orEmpty())
|
||||
put("containerExtension", containerExtension)
|
||||
put("directUrl", directUrl)
|
||||
}
|
||||
|
||||
private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto(
|
||||
name = optString("name"),
|
||||
streamId = optInt("streamId"),
|
||||
streamIcon = optString("streamIcon").takeIf { it.isNotBlank() },
|
||||
containerExtension = optString("containerExtension", "mp4"),
|
||||
directUrl = optString("directUrl")
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "vod_series_playback_progress"
|
||||
private const val DURATION_SUFFIX = ":duration"
|
||||
private const val COMPLETED_SUFFIX = ":completed"
|
||||
private const val LAST_WATCHED_SUFFIX = ":lastWatched"
|
||||
private const val METADATA_PREFIX = "meta:"
|
||||
private const val MIN_SAVED_POSITION_MS = 3_000L
|
||||
private const val FINISHED_THRESHOLD_MS = 30_000L
|
||||
private const val FINISHED_RATIO = 0.95f
|
||||
}
|
||||
}
|
||||
|
||||
data class ContinueMetadata(
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val stream: LiveStreamDto,
|
||||
val allStreams: List<LiveStreamDto>,
|
||||
val currentIndex: Int,
|
||||
val progressKeys: List<String>
|
||||
)
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val isCompleted: Boolean = false
|
||||
) {
|
||||
val hasProgress: Boolean = !isCompleted && positionMs >= 3_000L
|
||||
|
||||
val fraction: Float = when {
|
||||
isCompleted -> 1f
|
||||
hasProgress && durationMs > 0L -> (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val percent: Int = when {
|
||||
isCompleted -> 100
|
||||
fraction > 0f -> (fraction * 100f).roundToInt().coerceIn(1, 99)
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Stockage local très léger des dernières chaînes LIVE lancées.
|
||||
* L'objectif est d'afficher une ligne rapide sur le menu principal sans recharger le catalogue.
|
||||
*/
|
||||
class RecentLiveRepository(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun record(stream: LiveStreamDto) {
|
||||
if (stream.streamId <= 0 || stream.name.isBlank()) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = getRecentStreams().filterNot { it.stream.streamId == stream.streamId }.toMutableList()
|
||||
existing.add(0, RecentLiveEntry(stream = stream, lastUsedMs = now))
|
||||
|
||||
val payload = JSONArray().apply {
|
||||
existing.take(MAX_RECENT).forEach { entry -> put(entry.toJson()) }
|
||||
}
|
||||
prefs.edit().putString(KEY_RECENT, payload.toString()).apply()
|
||||
}
|
||||
|
||||
fun getRecentStreams(limit: Int = MAX_RECENT): List<RecentLiveEntry> {
|
||||
val raw = prefs.getString(KEY_RECENT, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (i in 0 until array.length()) {
|
||||
array.optJSONObject(i)?.toRecentLiveEntry()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
.filter { it.stream.streamId > 0 && it.stream.name.isNotBlank() }
|
||||
.distinctBy { it.stream.streamId }
|
||||
.sortedByDescending { it.lastUsedMs }
|
||||
.take(limit)
|
||||
}.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
private fun RecentLiveEntry.toJson(): JSONObject = JSONObject().apply {
|
||||
put("lastUsedMs", lastUsedMs)
|
||||
put("stream", stream.toJson())
|
||||
}
|
||||
|
||||
private fun JSONObject.toRecentLiveEntry(): RecentLiveEntry? {
|
||||
val streamJson = optJSONObject("stream") ?: return null
|
||||
return RecentLiveEntry(
|
||||
stream = streamJson.toLiveStreamDto(),
|
||||
lastUsedMs = optLong("lastUsedMs", 0L)
|
||||
)
|
||||
}
|
||||
|
||||
private fun LiveStreamDto.toJson(): JSONObject = JSONObject().apply {
|
||||
put("num", num)
|
||||
put("name", name)
|
||||
put("streamId", streamId)
|
||||
put("streamType", streamType)
|
||||
put("streamIcon", streamIcon.orEmpty())
|
||||
put("categoryId", categoryId)
|
||||
put("epgChannelId", epgChannelId.orEmpty())
|
||||
put("tvArchive", tvArchive)
|
||||
put("hasArchive", hasArchive)
|
||||
put("containerExtension", containerExtension)
|
||||
put("added", added)
|
||||
put("customSid", customSid.orEmpty())
|
||||
put("archiveDuration", archiveDuration)
|
||||
put("directUrl", directUrl)
|
||||
}
|
||||
|
||||
private fun JSONObject.toLiveStreamDto(): LiveStreamDto = LiveStreamDto(
|
||||
num = optInt("num", 0),
|
||||
name = optString("name"),
|
||||
streamId = optInt("streamId", 0),
|
||||
streamType = optString("streamType"),
|
||||
streamIcon = optString("streamIcon").takeIf { it.isNotBlank() },
|
||||
categoryId = optString("categoryId"),
|
||||
epgChannelId = optString("epgChannelId").takeIf { it.isNotBlank() },
|
||||
tvArchive = optInt("tvArchive", 0),
|
||||
hasArchive = optBoolean("hasArchive", false),
|
||||
containerExtension = optString("containerExtension", "m3u8"),
|
||||
added = optString("added"),
|
||||
customSid = optString("customSid").takeIf { it.isNotBlank() },
|
||||
archiveDuration = optInt("archiveDuration", 0),
|
||||
directUrl = optString("directUrl")
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "recent_live_channels"
|
||||
private const val KEY_RECENT = "recent_live_items"
|
||||
private const val MAX_RECENT = 12
|
||||
}
|
||||
}
|
||||
|
||||
data class RecentLiveEntry(
|
||||
val stream: LiveStreamDto,
|
||||
val lastUsedMs: Long
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Repository pour les séries.
|
||||
*/
|
||||
class SeriesRepository(
|
||||
private val api: XtreamApi,
|
||||
private val storage: SecureStorage,
|
||||
private val usageRepository: UsageRepository
|
||||
) {
|
||||
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
|
||||
|
||||
suspend fun getCategories(): UiState<List<SeriesCategoryDto>> = catchErrors {
|
||||
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
|
||||
val cats = api.getSeriesCategories(config.username, config.password)
|
||||
val sorted = CategorySortUtils.sortSeriesCategories(cats, usageRepository.seriesCategoryCounts())
|
||||
UiState.Success(sorted)
|
||||
}
|
||||
|
||||
suspend fun getSeriesList(categoryId: Int? = null): UiState<List<SeriesDto>> = catchErrors {
|
||||
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
|
||||
val series = api.getSeries(config.username, config.password, categoryId = categoryId)
|
||||
val sorted = UsageSortUtils.sortByUsage(series, usageRepository.seriesItemCounts()) { it.seriesId.toString() }
|
||||
UiState.Success(sorted)
|
||||
}
|
||||
|
||||
suspend fun getSeriesInfo(seriesId: Int): UiState<SeriesInfoDto> {
|
||||
return try {
|
||||
val config = requireConfig() ?: return UiState.Error("Not logged in")
|
||||
val info = api.getSeriesInfo(
|
||||
username = config.username,
|
||||
password = config.password,
|
||||
seriesId = seriesId
|
||||
)
|
||||
UiState.Success(info)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to load series info for %d", seriesId)
|
||||
UiState.Error("Failed to load series details.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
|
||||
/**
|
||||
* Stocke et lit les compteurs d'utilisation pour trier l'IPTV comme un être humain
|
||||
* qui ouvre toujours les mêmes chaînes, mais avec une télécommande.
|
||||
*/
|
||||
class UsageRepository(private val storage: SecureStorage) {
|
||||
|
||||
fun recordLiveCategory(categoryId: Int?) = record(BUCKET_LIVE_CATEGORY, categoryId)
|
||||
fun recordVodCategory(categoryId: Int?) = record(BUCKET_VOD_CATEGORY, categoryId)
|
||||
fun recordSeriesCategory(categoryId: Int?) = record(BUCKET_SERIES_CATEGORY, categoryId)
|
||||
|
||||
fun recordLiveStream(streamId: Int) = record(BUCKET_LIVE_STREAM, streamId)
|
||||
fun recordVodItem(streamId: Int) = record(BUCKET_VOD_ITEM, streamId)
|
||||
fun recordSeriesItem(seriesId: Int) = record(BUCKET_SERIES_ITEM, seriesId)
|
||||
|
||||
fun liveCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_CATEGORY)
|
||||
fun vodCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_CATEGORY)
|
||||
fun seriesCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_CATEGORY)
|
||||
|
||||
fun liveStreamCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_STREAM)
|
||||
fun vodItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_ITEM)
|
||||
fun seriesItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_ITEM)
|
||||
|
||||
private fun record(bucket: String, id: Int?) {
|
||||
if (id == null || id < 0) return
|
||||
storage.incrementUsageCounter(bucket, id.toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BUCKET_LIVE_CATEGORY = "live_category"
|
||||
private const val BUCKET_VOD_CATEGORY = "vod_category"
|
||||
private const val BUCKET_SERIES_CATEGORY = "series_category"
|
||||
private const val BUCKET_LIVE_STREAM = "live_stream"
|
||||
private const val BUCKET_VOD_ITEM = "vod_item"
|
||||
private const val BUCKET_SERIES_ITEM = "series_item"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Repository pour les VOD (films).
|
||||
*/
|
||||
class VodRepository(
|
||||
private val api: XtreamApi,
|
||||
private val storage: SecureStorage,
|
||||
private val usageRepository: UsageRepository
|
||||
) {
|
||||
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
|
||||
|
||||
suspend fun getCategories(): UiState<List<VodCategoryDto>> = catchErrors {
|
||||
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
|
||||
val cats = api.getVodCategories(config.username, config.password)
|
||||
val sorted = CategorySortUtils.sortVodCategories(cats, usageRepository.vodCategoryCounts())
|
||||
UiState.Success(sorted)
|
||||
}
|
||||
|
||||
suspend fun getMovies(categoryId: Int? = null): UiState<List<VodDto>> = catchErrors {
|
||||
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
|
||||
val movies = api.getVodStreams(config.username, config.password, categoryId = categoryId)
|
||||
val sorted = UsageSortUtils.sortByUsage(movies, usageRepository.vodItemCounts()) { it.streamId.toString() }
|
||||
UiState.Success(sorted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class YoutubeRepository(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getVideos(): List<YoutubeVideoItem> {
|
||||
val raw = prefs.getString(KEY_VIDEOS, "[]").orEmpty()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
add(
|
||||
YoutubeVideoItem(
|
||||
id = item.optString("id"),
|
||||
title = item.optString("title"),
|
||||
url = item.optString("url"),
|
||||
videoId = item.optString("videoId").takeIf { it.isNotBlank() },
|
||||
thumbnailUrl = item.optString("thumbnailUrl").takeIf { it.isNotBlank() },
|
||||
addedAtMs = item.optLong("addedAtMs", 0L),
|
||||
lastPlayedAtMs = item.optLong("lastPlayedAtMs", 0L)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
.filter { it.id.isNotBlank() && it.url.isNotBlank() }
|
||||
.sortedWith(compareByDescending<YoutubeVideoItem> { it.lastPlayedAtMs }.thenByDescending { it.addedAtMs })
|
||||
}
|
||||
|
||||
fun addVideo(title: String, input: String): Result<YoutubeVideoItem> {
|
||||
val target = YoutubeUrlUtils.parse(input)
|
||||
?: return Result.failure(IllegalArgumentException("Lien ou code YouTube invalide"))
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val id = target.videoId ?: stableId(target.normalizedUrl)
|
||||
val normalizedTitle = title.trim().takeIf { it.isNotBlank() }
|
||||
?: target.videoId?.let { "YouTube $it" }
|
||||
?: "Vidéo YouTube"
|
||||
|
||||
val newItem = YoutubeVideoItem(
|
||||
id = id,
|
||||
title = normalizedTitle,
|
||||
url = target.normalizedUrl,
|
||||
videoId = target.videoId,
|
||||
thumbnailUrl = target.thumbnailUrl(),
|
||||
addedAtMs = now,
|
||||
lastPlayedAtMs = now
|
||||
)
|
||||
|
||||
val merged = listOf(newItem) + getVideos().filterNot { it.id == id }
|
||||
saveVideos(merged)
|
||||
return Result.success(newItem)
|
||||
}
|
||||
|
||||
fun deleteVideo(id: String) {
|
||||
saveVideos(getVideos().filterNot { it.id == id })
|
||||
}
|
||||
|
||||
fun markPlayed(id: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
saveVideos(getVideos().map { item ->
|
||||
if (item.id == id) item.copy(lastPlayedAtMs = now) else item
|
||||
})
|
||||
}
|
||||
|
||||
private fun saveVideos(videos: List<YoutubeVideoItem>) {
|
||||
val array = JSONArray()
|
||||
videos.take(MAX_SAVED_VIDEOS).forEach { item ->
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("id", item.id)
|
||||
.put("title", item.title)
|
||||
.put("url", item.url)
|
||||
.put("videoId", item.videoId.orEmpty())
|
||||
.put("thumbnailUrl", item.thumbnailUrl.orEmpty())
|
||||
.put("addedAtMs", item.addedAtMs)
|
||||
.put("lastPlayedAtMs", item.lastPlayedAtMs)
|
||||
)
|
||||
}
|
||||
prefs.edit().putString(KEY_VIDEOS, array.toString()).apply()
|
||||
}
|
||||
|
||||
private fun stableId(value: String): String = "yt_${value.hashCode().absoluteValue}"
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "youtube_videos"
|
||||
private const val KEY_VIDEOS = "videos"
|
||||
private const val MAX_SAVED_VIDEOS = 80
|
||||
}
|
||||
}
|
||||
|
||||
data class YoutubeVideoItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val videoId: String?,
|
||||
val thumbnailUrl: String?,
|
||||
val addedAtMs: Long,
|
||||
val lastPlayedAtMs: Long
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
package ma.tkgraph.xtreamtv.data.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
|
||||
/**
|
||||
* Stockage sécurisé des credentials avec EncryptedSharedPreferences.
|
||||
*
|
||||
* - Keys : AES-256 SIV
|
||||
* - Values : AES-256 GCM
|
||||
* - MasterKey générée via AndroidKeyStore
|
||||
*/
|
||||
class SecureStorage(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
// ─── Config complète (Xtream ou M3U) ───
|
||||
|
||||
fun saveConfig(config: XtreamConfig) {
|
||||
prefs.edit()
|
||||
.putString(KEY_SOURCE_TYPE, config.sourceType.name)
|
||||
.putString(KEY_SERVER, config.serverUrl)
|
||||
.putString(KEY_USERNAME, config.username)
|
||||
.putString(KEY_PASSWORD, config.password)
|
||||
.putString(KEY_M3U_URL, config.m3uUrl)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getConfig(): XtreamConfig? {
|
||||
// Essayer le nouveau format (avec sourceType)
|
||||
val sourceTypeStr = prefs.getString(KEY_SOURCE_TYPE, null)
|
||||
if (sourceTypeStr != null) {
|
||||
val sourceType = try { SourceType.valueOf(sourceTypeStr) } catch (_: Exception) { SourceType.XTREAM }
|
||||
return when (sourceType) {
|
||||
SourceType.XTREAM -> {
|
||||
val server = prefs.getString(KEY_SERVER, null) ?: return null
|
||||
val username = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
|
||||
XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
|
||||
}
|
||||
SourceType.M3U -> {
|
||||
val m3uUrl = prefs.getString(KEY_M3U_URL, null) ?: return null
|
||||
XtreamConfig(sourceType = SourceType.M3U, m3uUrl = m3uUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback : ancien format (sans sourceType, session Xtream directe)
|
||||
val server = prefs.getString(KEY_SERVER, null) ?: return null
|
||||
val username = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
|
||||
return XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
|
||||
}
|
||||
|
||||
/** Retourne la config en ignorant le type (pour les migrations). */
|
||||
fun getCredentials(): XtreamConfig? = getConfig()
|
||||
|
||||
fun hasConfig(): Boolean = prefs.contains(KEY_SOURCE_TYPE)
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Efface uniquement la configuration de connexion.
|
||||
* Garde les favoris, l'historique local, les compteurs d'usage, etc.
|
||||
*/
|
||||
fun clearConfigPreservingLocalData() {
|
||||
prefs.edit()
|
||||
.remove(KEY_SOURCE_TYPE)
|
||||
.remove(KEY_SERVER)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_PASSWORD)
|
||||
.remove(KEY_M3U_URL)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ─── Favorites (Live) ───
|
||||
|
||||
fun getFavoriteLiveIds(): Set<Int> =
|
||||
prefs.getStringSet(KEY_FAV_LIVE, emptySet())
|
||||
?.mapNotNull { it.toIntOrNull() }
|
||||
?.toSet() ?: emptySet()
|
||||
|
||||
fun setFavoriteLiveIds(ids: Set<Int>) {
|
||||
prefs.edit().putStringSet(KEY_FAV_LIVE, ids.map { it.toString() }.toSet()).apply()
|
||||
}
|
||||
|
||||
// ─── Favorites (VOD) ───
|
||||
|
||||
fun getFavoriteVodIds(): Set<Int> =
|
||||
prefs.getStringSet(KEY_FAV_VOD, emptySet())
|
||||
?.mapNotNull { it.toIntOrNull() }
|
||||
?.toSet() ?: emptySet()
|
||||
|
||||
fun setFavoriteVodIds(ids: Set<Int>) {
|
||||
prefs.edit().putStringSet(KEY_FAV_VOD, ids.map { it.toString() }.toSet()).apply()
|
||||
}
|
||||
|
||||
// ─── Favorites (Series) ───
|
||||
|
||||
fun getFavoriteSeriesIds(): Set<Int> =
|
||||
prefs.getStringSet(KEY_FAV_SERIES, emptySet())
|
||||
?.mapNotNull { it.toIntOrNull() }
|
||||
?.toSet() ?: emptySet()
|
||||
|
||||
fun setFavoriteSeriesIds(ids: Set<Int>) {
|
||||
prefs.edit().putStringSet(KEY_FAV_SERIES, ids.map { it.toString() }.toSet()).apply()
|
||||
}
|
||||
|
||||
|
||||
// ─── Usage counters (tri par utilisation) ───
|
||||
|
||||
fun incrementUsageCounter(bucket: String, id: String) {
|
||||
val cleanId = id.trim()
|
||||
if (bucket.isBlank() || cleanId.isBlank()) return
|
||||
val current = getUsageCounters(bucket).toMutableMap()
|
||||
val nextValue = (current[cleanId] ?: 0) + 1
|
||||
current[cleanId] = nextValue.coerceAtMost(999_999)
|
||||
prefs.edit()
|
||||
.putStringSet(usageKey(bucket), current.map { (key, value) -> "$key=$value" }.toSet())
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun getUsageCounters(bucket: String): Map<String, Int> {
|
||||
if (bucket.isBlank()) return emptyMap()
|
||||
return prefs.getStringSet(usageKey(bucket), emptySet())
|
||||
?.mapNotNull { raw ->
|
||||
val key = raw.substringBefore('=', missingDelimiterValue = "").trim()
|
||||
val value = raw.substringAfter('=', missingDelimiterValue = "").toIntOrNull()
|
||||
if (key.isNotBlank() && value != null) key to value else null
|
||||
}
|
||||
?.toMap() ?: emptyMap()
|
||||
}
|
||||
|
||||
private fun usageKey(bucket: String): String = "usage_counter_$bucket"
|
||||
|
||||
// ─── Pinned Channel ───
|
||||
|
||||
fun getPinnedChannelId(): Int? {
|
||||
val id = prefs.getString(KEY_PINNED, null) ?: return null
|
||||
return id.toIntOrNull()
|
||||
}
|
||||
|
||||
fun setPinnedChannelId(streamId: Int?) {
|
||||
prefs.edit().putString(KEY_PINNED, streamId?.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "imedia_secure_prefs"
|
||||
|
||||
private const val KEY_SOURCE_TYPE = "source_type"
|
||||
private const val KEY_SERVER = "server_url"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_PASSWORD = "password"
|
||||
private const val KEY_M3U_URL = "m3u_url"
|
||||
private const val KEY_FAV_LIVE = "favorite_live"
|
||||
private const val KEY_FAV_VOD = "favorite_vod"
|
||||
private const val KEY_FAV_SERIES = "favorite_series"
|
||||
private const val KEY_PINNED = "pinned_channel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package ma.tkgraph.xtreamtv.ui.components
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
|
||||
private val TvKeyPageUp = Key(AndroidKeyEvent.KEYCODE_PAGE_UP.toLong())
|
||||
private val TvKeyPageDown = Key(AndroidKeyEvent.KEYCODE_PAGE_DOWN.toLong())
|
||||
private val TvKeyChannelUp = Key(AndroidKeyEvent.KEYCODE_CHANNEL_UP.toLong())
|
||||
private val TvKeyChannelDown = Key(AndroidKeyEvent.KEYCODE_CHANNEL_DOWN.toLong())
|
||||
|
||||
/**
|
||||
* Raccourcis Android TV pour les longues listes.
|
||||
*
|
||||
* Compatible avec les versions Compose qui n'exposent pas nativeKeyEvent.
|
||||
* - Appui long Haut : début de liste
|
||||
* - Appui long Bas : fin de liste
|
||||
* - Channel+/PageUp : saut vers le haut
|
||||
* - Channel-/PageDown: saut vers le bas
|
||||
*/
|
||||
fun Modifier.tvFastScrollKeys(
|
||||
enabled: Boolean = true,
|
||||
onJumpToTop: () -> Unit,
|
||||
onJumpToBottom: () -> Unit,
|
||||
onPageUp: () -> Unit,
|
||||
onPageDown: () -> Unit
|
||||
): Modifier = composed {
|
||||
if (!enabled) return@composed this
|
||||
|
||||
var upKeyDownCount by remember { mutableIntStateOf(0) }
|
||||
var downKeyDownCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
this.onPreviewKeyEvent { event ->
|
||||
when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
when (event.key) {
|
||||
TvKeyChannelUp, TvKeyPageUp -> {
|
||||
upKeyDownCount = 0
|
||||
downKeyDownCount = 0
|
||||
onPageUp()
|
||||
true
|
||||
}
|
||||
|
||||
TvKeyChannelDown, TvKeyPageDown -> {
|
||||
upKeyDownCount = 0
|
||||
downKeyDownCount = 0
|
||||
onPageDown()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
upKeyDownCount += 1
|
||||
downKeyDownCount = 0
|
||||
|
||||
// Le premier KeyDown reste la navigation normale.
|
||||
// Les répétitions envoyées pendant l'appui long déclenchent le saut direct.
|
||||
if (upKeyDownCount >= 2) {
|
||||
onJumpToTop()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionDown -> {
|
||||
downKeyDownCount += 1
|
||||
upKeyDownCount = 0
|
||||
|
||||
if (downKeyDownCount >= 2) {
|
||||
onJumpToBottom()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
upKeyDownCount = 0
|
||||
downKeyDownCount = 0
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (event.key == Key.DirectionUp) upKeyDownCount = 0
|
||||
if (event.key == Key.DirectionDown) downKeyDownCount = 0
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Modifier qui ajoute un contour lumineux visible quand l'élément ou un de ses enfants a le focus.
|
||||
* Idéal pour la navigation télécommande Android TV.
|
||||
*
|
||||
* @param focusedBorderColor Couleur du contour quand focusé
|
||||
* @param unfocusedBorderColor Couleur du contour quand pas focusé (transparent par défaut)
|
||||
* @param borderWidth Épaisseur du contour
|
||||
* @param cornerRadius Arrondi des coins
|
||||
*/
|
||||
fun Modifier.tvFocusHighlight(
|
||||
focusedBorderColor: Color = Color(0xFFE50914),
|
||||
unfocusedBorderColor: Color = Color.Transparent,
|
||||
borderWidth: Dp = 3.dp,
|
||||
cornerRadius: Dp = 12.dp
|
||||
): Modifier = composed {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 150)
|
||||
)
|
||||
|
||||
this
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(
|
||||
width = borderWidth,
|
||||
color = focusedBorderColor.copy(alpha = animatedAlpha),
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
)
|
||||
} else {
|
||||
Modifier.border(
|
||||
width = borderWidth,
|
||||
color = unfocusedBorderColor,
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package ma.tkgraph.xtreamtv.ui.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key as ComposeKey
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
||||
/**
|
||||
* Commande envoyée depuis PlayerScreen vers le mini lecteur YouTube.
|
||||
* Le nonce force Compose à rejouer la commande même si le même bouton est pressé plusieurs fois.
|
||||
*/
|
||||
data class YoutubeRemoteCommand(
|
||||
val type: YoutubeCommandType,
|
||||
val nonce: Long = System.nanoTime()
|
||||
)
|
||||
|
||||
enum class YoutubeCommandType {
|
||||
TogglePlayPause,
|
||||
SeekBackward,
|
||||
SeekForward
|
||||
}
|
||||
|
||||
private enum class YoutubeLoadMode {
|
||||
Embed,
|
||||
Site
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémoire légère de session : garde le dernier lien saisi tant que le processus
|
||||
* de l'application reste ouvert. Rien n'est écrit sur disque.
|
||||
*/
|
||||
private object YoutubeMiniPlayerSessionMemory {
|
||||
var lastInputText: String = DEFAULT_YOUTUBE_WATCH_PREFIX
|
||||
}
|
||||
|
||||
private data class YoutubeEmbedTarget(
|
||||
val normalizedUrl: String,
|
||||
val videoId: String?,
|
||||
val playlistId: String?,
|
||||
val channelOrSiteUrl: String?
|
||||
) {
|
||||
val canUseEmbed: Boolean = videoId != null || playlistId != null
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun YoutubeMiniPlayer(
|
||||
modifier: Modifier = Modifier,
|
||||
remoteCommand: YoutubeRemoteCommand? = null,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val loadButtonFocusRequester = remember { FocusRequester() }
|
||||
val minimizeButtonFocusRequester = remember { FocusRequester() }
|
||||
val closeButtonFocusRequester = remember { FocusRequester() }
|
||||
val minimizedFocusRequester = remember { FocusRequester() }
|
||||
var isInputFocused by remember { mutableStateOf(false) }
|
||||
var inputUrl by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
val rememberedInput = YoutubeMiniPlayerSessionMemory.lastInputText
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: DEFAULT_YOUTUBE_WATCH_PREFIX
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = rememberedInput,
|
||||
selection = TextRange(rememberedInput.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
var loadedUrl by rememberSaveable { mutableStateOf("") }
|
||||
var loadModeName by rememberSaveable { mutableStateOf(YoutubeLoadMode.Embed.name) }
|
||||
var status by remember { mutableStateOf("V8 focus : ajoutez le code puis Charger") }
|
||||
var isMinimized by rememberSaveable { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
val loadMode = remember(loadModeName) {
|
||||
runCatching { YoutubeLoadMode.valueOf(loadModeName) }.getOrDefault(YoutubeLoadMode.Embed)
|
||||
}
|
||||
val target = remember(loadedUrl) { parseYoutubeTarget(loadedUrl) }
|
||||
|
||||
fun exitInputAndFocusLoadButton() {
|
||||
focusManager.clearFocus(force = true)
|
||||
runCatching { loadButtonFocusRequester.requestFocus() }
|
||||
status = "Champ quitté : OK sur Charger"
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = !isMinimized) {
|
||||
if (isInputFocused) {
|
||||
exitInputAndFocusLoadButton()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = isMinimized) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
fun sendCommand(type: YoutubeCommandType) {
|
||||
val js = when (type) {
|
||||
YoutubeCommandType.TogglePlayPause -> "if(window.togglePlay){togglePlay();}"
|
||||
YoutubeCommandType.SeekBackward -> "if(window.seekBy){seekBy(-10);}"
|
||||
YoutubeCommandType.SeekForward -> "if(window.seekBy){seekBy(10);}"
|
||||
}
|
||||
webView?.evaluateJavascript(js, null)
|
||||
}
|
||||
|
||||
fun loadUrl(mode: YoutubeLoadMode) {
|
||||
val normalized = normalizeYoutubeUrl(inputUrl.text)
|
||||
YoutubeMiniPlayerSessionMemory.lastInputText = inputUrl.text
|
||||
loadedUrl = normalized
|
||||
loadModeName = mode.name
|
||||
status = when {
|
||||
normalized.isBlank() -> "Ajoutez le code vidéo après watch?v="
|
||||
mode == YoutubeLoadMode.Site -> "Chargement du site YouTube"
|
||||
else -> "Chargement du player intégré"
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromMinimized() {
|
||||
isMinimized = false
|
||||
status = "Mode normal : OK = Play/Pause"
|
||||
}
|
||||
|
||||
LaunchedEffect(remoteCommand) {
|
||||
remoteCommand?.let { sendCommand(it.type) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { inputFocusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
LaunchedEffect(isMinimized) {
|
||||
if (isMinimized) {
|
||||
status = "Mode minimisé : OK pour agrandir"
|
||||
runCatching { minimizedFocusRequester.requestFocus() }
|
||||
} else {
|
||||
runCatching { inputFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
val surfaceModifier = modifier.then(
|
||||
if (isMinimized) {
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.alpha(0.30f)
|
||||
.focusRequester(minimizedFocusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp && (event.key == Key.DirectionCenter || event.key == Key.Enter)) {
|
||||
restoreFromMinimized()
|
||||
true
|
||||
} else if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
|
||||
onClose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
.width(520.dp)
|
||||
.onPreviewKeyEvent { event ->
|
||||
when {
|
||||
// Filet de sécurité au niveau du conteneur.
|
||||
isInputFocused && (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) &&
|
||||
(event.key == Key.DirectionRight || event.key == Key.DirectionDown || event.key == Key.Tab || event.key == Key.Back) -> {
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
exitInputAndFocusLoadButton()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// OK depuis le champ charge directement, utile sur les Mi Box qui gardent le focus texte.
|
||||
isInputFocused && event.type == KeyEventType.KeyDown &&
|
||||
(event.key == Key.DirectionCenter || event.key == Key.Enter) -> {
|
||||
loadUrl(YoutubeLoadMode.Embed)
|
||||
exitInputAndFocusLoadButton()
|
||||
true
|
||||
}
|
||||
|
||||
event.type == KeyEventType.KeyUp && event.key == Key.Back -> {
|
||||
onClose()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = surfaceModifier,
|
||||
color = Color.Black.copy(alpha = if (isMinimized) 0.34f else 0.88f),
|
||||
shape = RoundedCornerShape(if (isMinimized) 14.dp else 18.dp),
|
||||
shadowElevation = if (isMinimized) 2.dp else 10.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.border(
|
||||
1.dp,
|
||||
Color(0xFF00D5FF).copy(alpha = if (isMinimized) 0.16f else 0.45f),
|
||||
RoundedCornerShape(if (isMinimized) 14.dp else 18.dp)
|
||||
)
|
||||
.padding(if (isMinimized) 5.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isMinimized) 0.dp else 8.dp)
|
||||
) {
|
||||
if (!isMinimized) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "YouTube discret · V8",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "TV muette · OK Play/Pause · Retour fermer",
|
||||
color = Color(0xFFB8C7D1),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.focusRequester(closeButtonFocusRequester)
|
||||
) {
|
||||
Text("Fermer", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputUrl,
|
||||
onValueChange = {
|
||||
inputUrl = it
|
||||
YoutubeMiniPlayerSessionMemory.lastInputText = it.text
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { isInputFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val exitKeys = event.key == Key.DirectionRight ||
|
||||
event.key == Key.DirectionDown ||
|
||||
event.key == Key.Tab ||
|
||||
event.key == Key.Back
|
||||
val loadKeys = event.key == Key.DirectionCenter || event.key == Key.Enter
|
||||
|
||||
when {
|
||||
(event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && exitKeys -> {
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
exitInputAndFocusLoadButton()
|
||||
}
|
||||
true
|
||||
}
|
||||
event.type == KeyEventType.KeyDown && loadKeys -> {
|
||||
loadUrl(YoutubeLoadMode.Embed)
|
||||
exitInputAndFocusLoadButton()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
label = { Text("Lien ou code YouTube") },
|
||||
placeholder = { Text("CTGbG3bygLg") }
|
||||
)
|
||||
Button(
|
||||
onClick = { loadUrl(YoutubeLoadMode.Embed) },
|
||||
modifier = Modifier.focusRequester(loadButtonFocusRequester),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00A9D6))
|
||||
) {
|
||||
Text("Charger")
|
||||
}
|
||||
Button(
|
||||
onClick = { isMinimized = true },
|
||||
modifier = Modifier.focusRequester(minimizeButtonFocusRequester),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF44515A))
|
||||
) {
|
||||
Text("Minimiser")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (isMinimized) 112.dp else 240.dp)
|
||||
.background(Color.Black, RoundedCornerShape(if (isMinimized) 10.dp else 12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (loadedUrl.isBlank()) {
|
||||
Text("Aucun lien chargé", color = Color(0xFFB8C7D1))
|
||||
} else {
|
||||
ComposeKey("hermes-youtube-webview") {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.databaseEnabled = true
|
||||
settings.mediaPlaybackRequiresUserGesture = false
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||
settings.setSupportMultipleWindows(false)
|
||||
settings.userAgentString = DESKTOP_USER_AGENT
|
||||
|
||||
runCatching {
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
val message = consoleMessage?.message().orEmpty()
|
||||
if (message.startsWith("HERMES_YT:")) {
|
||||
status = message.removePrefix("HERMES_YT:")
|
||||
return true
|
||||
}
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if (!status.startsWith("YouTube erreur") && !isMinimized) {
|
||||
status = if (loadMode == YoutubeLoadMode.Site) {
|
||||
"Site YouTube chargé : cliquez Play si nécessaire"
|
||||
} else {
|
||||
"Player chargé : OK = Play/Pause"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
status = "Erreur WebView : ${error?.description ?: "chargement impossible"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
setBackgroundColor(android.graphics.Color.BLACK)
|
||||
}.also { webView = it }
|
||||
},
|
||||
update = { view ->
|
||||
webView = view
|
||||
val currentTarget = target
|
||||
val tagValue = "${loadedUrl}|${loadMode.name}"
|
||||
if (view.tag != tagValue) {
|
||||
view.tag = tagValue
|
||||
when {
|
||||
currentTarget == null -> Unit
|
||||
loadMode == YoutubeLoadMode.Site -> {
|
||||
view.loadUrl(
|
||||
currentTarget.toWatchOrNormalizedUrl(),
|
||||
youtubeHeaders()
|
||||
)
|
||||
}
|
||||
currentTarget.canUseEmbed -> {
|
||||
view.loadDataWithBaseURL(
|
||||
HERMES_YOUTUBE_ORIGIN,
|
||||
buildYoutubeStaticIframeHtml(currentTarget),
|
||||
"text/html",
|
||||
"utf-8",
|
||||
currentTarget.toEmbedUrl()
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
view.loadUrl(
|
||||
currentTarget.toWatchOrNormalizedUrl(),
|
||||
youtubeHeaders()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMinimized) {
|
||||
Button(
|
||||
onClick = { restoreFromMinimized() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(3.dp)
|
||||
.focusRequester(minimizedFocusRequester),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Black.copy(alpha = 0.58f))
|
||||
) {
|
||||
Text("OK agrandir", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMinimized) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(onClick = { sendCommand(YoutubeCommandType.SeekBackward) }) { Text("-10s") }
|
||||
Button(onClick = { sendCommand(YoutubeCommandType.TogglePlayPause) }) { Text("Pause / Play") }
|
||||
Button(onClick = { sendCommand(YoutubeCommandType.SeekForward) }) { Text("+10s") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = status,
|
||||
color = Color(0xFFB8C7D1),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_YOUTUBE_WATCH_PREFIX = "https://www.youtube.com/watch?v="
|
||||
private val YOUTUBE_VIDEO_ID_PATTERN = Regex("^[A-Za-z0-9_-]{8,20}$")
|
||||
|
||||
private const val HERMES_YOUTUBE_ORIGIN = "https://hermes-youtube.local/"
|
||||
private const val DESKTOP_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
private fun youtubeHeaders(): Map<String, String> = mapOf(
|
||||
"Referer" to HERMES_YOUTUBE_ORIGIN,
|
||||
"Origin" to HERMES_YOUTUBE_ORIGIN.trimEnd('/')
|
||||
)
|
||||
|
||||
private fun normalizeYoutubeUrl(raw: String): String {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank() || value == DEFAULT_YOUTUBE_WATCH_PREFIX) return ""
|
||||
|
||||
return when {
|
||||
value.startsWith("http://") || value.startsWith("https://") -> value
|
||||
value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value"
|
||||
value.startsWith("watch?v=") -> "https://www.youtube.com/$value"
|
||||
value.startsWith("v=") -> DEFAULT_YOUTUBE_WATCH_PREFIX + value.removePrefix("v=")
|
||||
YOUTUBE_VIDEO_ID_PATTERN.matches(value) -> DEFAULT_YOUTUBE_WATCH_PREFIX + value
|
||||
else -> "https://$value"
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseYoutubeTarget(rawUrl: String): YoutubeEmbedTarget? {
|
||||
val normalized = normalizeYoutubeUrl(rawUrl)
|
||||
if (normalized.isBlank()) return null
|
||||
|
||||
return runCatching {
|
||||
val uri = Uri.parse(normalized)
|
||||
val host = uri.host.orEmpty().lowercase()
|
||||
val pathSegments = uri.pathSegments
|
||||
|
||||
val videoId = when {
|
||||
host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull()
|
||||
uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v")
|
||||
pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1]
|
||||
else -> null
|
||||
}?.takeIf { it.length in 8..20 }
|
||||
|
||||
val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() }
|
||||
|
||||
YoutubeEmbedTarget(
|
||||
normalizedUrl = normalized,
|
||||
videoId = videoId,
|
||||
playlistId = playlistId,
|
||||
channelOrSiteUrl = normalized
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun YoutubeEmbedTarget.toEmbedUrl(): String {
|
||||
val origin = HERMES_YOUTUBE_ORIGIN.trimEnd('/').urlEncode()
|
||||
val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin"
|
||||
return when {
|
||||
videoId != null -> "https://www.youtube.com/embed/$videoId?$common"
|
||||
playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${playlistId.urlEncode()}&$common"
|
||||
else -> normalizedUrl
|
||||
}
|
||||
}
|
||||
|
||||
private fun YoutubeEmbedTarget.toWatchOrNormalizedUrl(): String {
|
||||
return when {
|
||||
videoId != null -> "https://www.youtube.com/watch?v=$videoId"
|
||||
else -> channelOrSiteUrl ?: normalizedUrl
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildYoutubeStaticIframeHtml(target: YoutubeEmbedTarget): String {
|
||||
val embedUrl = target.toEmbedUrl().escapeHtml()
|
||||
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<style>
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#ytplayer { width:100%; height:100%; background:#000; border:0; display:block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="ytplayer"
|
||||
src="$embedUrl"
|
||||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin">
|
||||
</iframe>
|
||||
<script src="https://www.youtube.com/iframe_api"></script>
|
||||
<script>
|
||||
var player = null;
|
||||
var lastState = -1;
|
||||
|
||||
function logHermes(message) {
|
||||
try { console.log('HERMES_YT:' + message); } catch(e) {}
|
||||
}
|
||||
|
||||
function onYouTubeIframeAPIReady() {
|
||||
player = new YT.Player('ytplayer', {
|
||||
events: {
|
||||
'onReady': function(event) {
|
||||
logHermes('Player prêt');
|
||||
try { event.target.playVideo(); } catch(e) { logHermes('Cliquez Play si nécessaire'); }
|
||||
},
|
||||
'onStateChange': function(event) {
|
||||
lastState = event.data;
|
||||
if (event.data === YT.PlayerState.PLAYING) logHermes('Lecture YouTube');
|
||||
if (event.data === YT.PlayerState.PAUSED) logHermes('Pause YouTube');
|
||||
if (event.data === YT.PlayerState.BUFFERING) logHermes('Chargement YouTube');
|
||||
},
|
||||
'onError': function(event) {
|
||||
logHermes('YouTube erreur ' + event.data + ' : embed refusé');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!player || !player.getPlayerState) return;
|
||||
var state = player.getPlayerState();
|
||||
if (state === YT.PlayerState.PLAYING) {
|
||||
player.pauseVideo();
|
||||
} else {
|
||||
player.playVideo();
|
||||
}
|
||||
}
|
||||
|
||||
function seekBy(seconds) {
|
||||
if (!player || !player.getCurrentTime || !player.seekTo) return;
|
||||
var current = 0;
|
||||
try { current = player.getCurrentTime(); } catch(e) { current = 0; }
|
||||
var next = Math.max(0, current + seconds);
|
||||
player.seekTo(next, true);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun String.escapeHtml(): String =
|
||||
replace("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
|
||||
private fun String.urlEncode(): String = Uri.encode(this)
|
||||
@@ -0,0 +1,51 @@
|
||||
package ma.tkgraph.xtreamtv.ui.navigation
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
|
||||
|
||||
/**
|
||||
* État de navigation de l'application.
|
||||
* Chaque écran est un état avec ses propres données contextuelles.
|
||||
*/
|
||||
sealed class NavState {
|
||||
/** Menu d’accueil principal : TV / VOD / Séries. */
|
||||
data object MainMenu : NavState()
|
||||
|
||||
/** Écran TV Live avec catégories et chaînes. */
|
||||
data class Home(val focusStreamId: Int? = null) : NavState()
|
||||
|
||||
/** Lecteur vidéo. */
|
||||
data class Player(
|
||||
val stream: LiveStreamDto,
|
||||
val allStreams: List<LiveStreamDto> = emptyList(),
|
||||
val currentIndex: Int = 0,
|
||||
val replayUrl: String? = null,
|
||||
val returnState: NavState? = null,
|
||||
val progressKey: String? = null,
|
||||
val progressKeys: List<String> = emptyList()
|
||||
) : NavState()
|
||||
|
||||
/** Recherche globale LIVE / VOD / Séries. */
|
||||
data object GlobalSearch : NavState()
|
||||
|
||||
/** Catalogue local de vidéos YouTube. */
|
||||
data object Youtube : NavState()
|
||||
|
||||
/** Guide TV global. */
|
||||
data object ProgramGuide : NavState()
|
||||
|
||||
/** Guide Replay pour une chaîne spécifique. */
|
||||
data class ReplayGuide(val stream: LiveStreamDto) : NavState()
|
||||
|
||||
/** Catalogue VOD (films). */
|
||||
data class Vod(val initialCategoryId: Int? = null) : NavState()
|
||||
|
||||
/** Catalogue séries. */
|
||||
data class Series(val initialCategoryId: Int? = null) : NavState()
|
||||
|
||||
/** Détail d'une série (saisons + épisodes). */
|
||||
data class SeriesDetail(val series: SeriesDto, val info: SeriesInfoDto) : NavState()
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResult
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchResultType
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.GlobalSearchViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.SearchScope
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchScreen(
|
||||
viewModel: GlobalSearchViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val searchScope by viewModel.scope.collectAsStateWithLifecycle()
|
||||
val resultsState by viewModel.results.collectAsStateWithLifecycle()
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(160)
|
||||
runCatching { searchFocusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = DesignColors.bg1) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)))
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = DesignColors.orange,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("◀ Retour", color = DesignColors.muted) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
"Recherche globale",
|
||||
color = DesignColors.text,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(searchFocusRequester),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = DesignColors.orange) },
|
||||
placeholder = { Text("Rechercher une chaîne, un film ou une série…") }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SearchScope.entries.forEach { scope ->
|
||||
SearchScopeButton(
|
||||
text = scope.label,
|
||||
selected = searchScope == scope,
|
||||
onClick = { viewModel.setScope(scope) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = viewModel::runSearch,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = DesignColors.orange,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 10.dp
|
||||
)
|
||||
) {
|
||||
Text("Lancer", color = DesignColors.orange, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
when (val results = resultsState) {
|
||||
is UiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = DesignColors.orange)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Recherche en cours…", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
is UiState.Error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(results.message, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
is UiState.Success -> {
|
||||
val data = results.data
|
||||
if (query.trim().length < 2) {
|
||||
SearchEmptyMessage("Tape au moins 2 caractères, choisis LIVE/VOD/SÉRIES/ALL, puis lance la recherche.")
|
||||
} else if (data.isEmpty()) {
|
||||
SearchEmptyMessage("Aucun résultat affiché. Lance une recherche ou change de filtre.")
|
||||
} else {
|
||||
Text(
|
||||
"${data.size} résultat(s)",
|
||||
color = DesignColors.muted,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(data, key = { it.id }) { result ->
|
||||
SearchResultRow(
|
||||
result = result,
|
||||
onClick = {
|
||||
when (result.type) {
|
||||
GlobalSearchResultType.Live -> {
|
||||
val stream = result.liveStream
|
||||
if (stream != null) {
|
||||
viewModel.recordLiveUsage(stream)
|
||||
onNavigate(
|
||||
NavState.Player(
|
||||
stream = stream,
|
||||
allStreams = listOf(stream),
|
||||
currentIndex = 0,
|
||||
returnState = NavState.GlobalSearch
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
GlobalSearchResultType.Vod -> {
|
||||
val movie = result.vod
|
||||
val stream = movie?.let { viewModel.buildVodStream(it) }
|
||||
if (movie != null && stream != null) {
|
||||
viewModel.recordVodUsage(movie.streamId)
|
||||
onNavigate(
|
||||
NavState.Player(
|
||||
stream = stream,
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
replayUrl = stream.directUrl,
|
||||
returnState = NavState.GlobalSearch,
|
||||
progressKey = "vod:${movie.streamId}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
GlobalSearchResultType.Series -> {
|
||||
val series = result.series
|
||||
if (series != null) {
|
||||
viewModel.recordSeriesUsage(series.seriesId)
|
||||
coroutineScope.launch {
|
||||
val launch = viewModel.buildSeriesLaunch(series) ?: return@launch
|
||||
onNavigate(
|
||||
NavState.Player(
|
||||
stream = launch.stream,
|
||||
allStreams = launch.allStreams,
|
||||
currentIndex = launch.currentIndex,
|
||||
replayUrl = null,
|
||||
returnState = NavState.GlobalSearch,
|
||||
progressKey = launch.progressKey,
|
||||
progressKeys = launch.progressKeys
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> SearchEmptyMessage("Recherche prête.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchScopeButton(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) DesignColors.orange else DesignColors.stroke,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = DesignColors.orange,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 10.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
color = if (selected) DesignColors.orange else DesignColors.muted,
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyMessage(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text, color = DesignColors.muted, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(result: GlobalSearchResult, onClick: () -> Unit) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val accent = when (result.type) {
|
||||
GlobalSearchResultType.Live -> DesignColors.orange
|
||||
GlobalSearchResultType.Vod -> DesignColors.red
|
||||
GlobalSearchResultType.Series -> DesignColors.green
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(86.dp)
|
||||
.onFocusChanged { focused = it.isFocused || it.hasFocus }
|
||||
.focusable()
|
||||
.border(
|
||||
width = if (focused) 3.dp else 1.dp,
|
||||
color = if (focused) accent else DesignColors.stroke.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (focused) Color(0x22FFFFFF) else DesignColors.panel
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 68.dp, height = 62.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF141414)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!result.imageUrl.isNullOrBlank()) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = result.imageUrl,
|
||||
contentDescription = result.title,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text(result.type.name.take(3).uppercase(), color = accent, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
result.title,
|
||||
color = DesignColors.text,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
result.subtitle,
|
||||
color = DesignColors.muted,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text("OK", color = accent, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,599 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.text.InputType
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.content.Context
|
||||
import android.widget.EditText
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel
|
||||
|
||||
|
||||
private enum class LoginInputField {
|
||||
SERVER_URL,
|
||||
USERNAME,
|
||||
PASSWORD,
|
||||
M3U_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de connexion / configuration.
|
||||
* Permet de choisir entre serveur Xtream et fichier M3U.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = viewModel(),
|
||||
onLoginSuccess: () -> Unit = {}
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val serverUrl = viewModel.serverUrl
|
||||
val username = viewModel.username
|
||||
val password = viewModel.password
|
||||
val m3uUrl = viewModel.m3uUrl
|
||||
val sourceType = viewModel.sourceType
|
||||
var showModeMenu by remember { mutableStateOf(false) }
|
||||
var editingField by remember { mutableStateOf<LoginInputField?>(null) }
|
||||
val serverFocusRequester = remember { FocusRequester() }
|
||||
val usernameFocusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
val m3uFocusRequester = remember { FocusRequester() }
|
||||
val submitFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(sourceType) {
|
||||
runCatching {
|
||||
if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus()
|
||||
else m3uFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
if (uiState is UiState.Success) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si des credentials sont déjà stockés
|
||||
val savedConfig = remember {
|
||||
XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
}
|
||||
|
||||
when (editingField) {
|
||||
LoginInputField.SERVER_URL -> NativeLoginInputDialog(
|
||||
title = "URL du serveur",
|
||||
value = serverUrl,
|
||||
hint = "http://serveur:8080",
|
||||
onDismiss = { editingField = null },
|
||||
onConfirm = {
|
||||
viewModel.updateServerUrl(it)
|
||||
editingField = null
|
||||
}
|
||||
)
|
||||
LoginInputField.USERNAME -> NativeLoginInputDialog(
|
||||
title = "Identifiant",
|
||||
value = username,
|
||||
hint = "Identifiant",
|
||||
onDismiss = { editingField = null },
|
||||
onConfirm = {
|
||||
viewModel.updateUsername(it)
|
||||
editingField = null
|
||||
}
|
||||
)
|
||||
LoginInputField.PASSWORD -> NativeLoginInputDialog(
|
||||
title = "Mot de passe",
|
||||
value = password,
|
||||
hint = "Mot de passe",
|
||||
password = true,
|
||||
onDismiss = { editingField = null },
|
||||
onConfirm = {
|
||||
viewModel.updatePassword(it)
|
||||
editingField = null
|
||||
}
|
||||
)
|
||||
LoginInputField.M3U_URL -> NativeLoginInputDialog(
|
||||
title = "URL du fichier M3U",
|
||||
value = m3uUrl,
|
||||
hint = "https://exemple.com/playlist.m3u",
|
||||
onDismiss = { editingField = null },
|
||||
onConfirm = {
|
||||
viewModel.updateM3uUrl(it)
|
||||
editingField = null
|
||||
}
|
||||
)
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = DesignColors.bg1
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
|
||||
)
|
||||
)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
// Titre avec gradient orange
|
||||
Text(
|
||||
text = "iMedia TV+",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(DesignColors.orange, DesignColors.orange2)
|
||||
)
|
||||
),
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = (-0.055).sp
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Configuration",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF888888)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// ─── Sélecteur de mode ───
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ModeButton(
|
||||
label = "Xtream",
|
||||
isSelected = sourceType == SourceType.XTREAM,
|
||||
onClick = { viewModel.switchSourceType(SourceType.XTREAM) }
|
||||
)
|
||||
ModeButton(
|
||||
label = "M3U",
|
||||
isSelected = sourceType == SourceType.M3U,
|
||||
onClick = { viewModel.switchSourceType(SourceType.M3U) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// ─── Champs selon le mode ───
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (sourceType == SourceType.XTREAM) {
|
||||
// ── Mode Xtream ──
|
||||
Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Flèches haut/bas pour changer de champ. OK pour saisir.",
|
||||
color = Color(0xFF888888),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
TvEditableLoginField(
|
||||
label = "URL du serveur",
|
||||
value = serverUrl,
|
||||
hint = "http://serveur:8080",
|
||||
enabled = uiState !is UiState.Loading,
|
||||
onClick = { editingField = LoginInputField.SERVER_URL },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(serverFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
down = { usernameFocusRequester.requestFocus() }
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
TvEditableLoginField(
|
||||
label = "Identifiant",
|
||||
value = username,
|
||||
hint = "Identifiant",
|
||||
enabled = uiState !is UiState.Loading,
|
||||
onClick = { editingField = LoginInputField.USERNAME },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(usernameFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { serverFocusRequester.requestFocus() },
|
||||
down = { passwordFocusRequester.requestFocus() }
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
TvEditableLoginField(
|
||||
label = "Mot de passe",
|
||||
value = password,
|
||||
hint = "Mot de passe",
|
||||
password = true,
|
||||
enabled = uiState !is UiState.Loading,
|
||||
onClick = { editingField = LoginInputField.PASSWORD },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(passwordFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { usernameFocusRequester.requestFocus() },
|
||||
down = { submitFocusRequester.requestFocus() },
|
||||
right = { submitFocusRequester.requestFocus() }
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.login() },
|
||||
enabled = uiState !is UiState.Loading
|
||||
&& serverUrl.isNotBlank()
|
||||
&& username.isNotBlank()
|
||||
&& password.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(48.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.focusRequester(submitFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { passwordFocusRequester.requestFocus() },
|
||||
left = { passwordFocusRequester.requestFocus() }
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914))
|
||||
) {
|
||||
if (uiState is UiState.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Mode M3U ──
|
||||
Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Flèche bas pour descendre. OK pour saisir.",
|
||||
color = Color(0xFF888888),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
TvEditableLoginField(
|
||||
label = "URL du fichier M3U",
|
||||
value = m3uUrl,
|
||||
hint = "https://exemple.com/playlist.m3u",
|
||||
onClick = { editingField = LoginInputField.M3U_URL },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(m3uFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
down = { submitFocusRequester.requestFocus() }
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Entrez l'URL d'un fichier M3U distant",
|
||||
color = Color(0xFF666666),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.saveM3uConfig() },
|
||||
enabled = m3uUrl.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(48.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.focusRequester(submitFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { m3uFocusRequester.requestFocus() }
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
|
||||
) {
|
||||
Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message d'erreur
|
||||
if (uiState is UiState.Error) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) {
|
||||
Text(
|
||||
text = (uiState as UiState.Error).message,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color(0xFFFF5252)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Si déjà connecté, afficher les infos
|
||||
if (savedConfig != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color(0xFF1A3A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { viewModel.logout() }) {
|
||||
Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun TvEditableLoginField(
|
||||
label: String,
|
||||
value: String,
|
||||
hint: String,
|
||||
password: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val visibleValue = when {
|
||||
value.isBlank() -> hint
|
||||
password -> "●".repeat(value.length.coerceAtLeast(1))
|
||||
else -> value
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
label,
|
||||
color = if (isFocused) Color(0xFFE50914) else Color(0xFFAAAAAA),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 2.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
if (enabled) onClick()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color(0xFF262626),
|
||||
border = BorderStroke(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) Color(0xFFE50914) else Color(0xFF444444)
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.focusable(enabled)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
visibleValue,
|
||||
color = if (value.isBlank()) Color(0xFF777777) else Color.White,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"OK",
|
||||
color = if (isFocused) Color(0xFFE50914) else Color(0xFF777777),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeLoginInputDialog(
|
||||
title: String,
|
||||
value: String,
|
||||
hint: String,
|
||||
password: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
DisposableEffect(title, value, hint, password) {
|
||||
var handled = false
|
||||
|
||||
val editText = EditText(context).apply {
|
||||
setSingleLine(true)
|
||||
setText(value)
|
||||
setHint(hint)
|
||||
setSelection(text?.length ?: 0)
|
||||
textSize = 18f
|
||||
inputType = if (password) {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
}
|
||||
setPadding(24, 12, 24, 12)
|
||||
}
|
||||
|
||||
val dialog = android.app.AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setView(editText)
|
||||
.setPositiveButton("OK") { _, _ ->
|
||||
handled = true
|
||||
onConfirm(editText.text?.toString().orEmpty())
|
||||
}
|
||||
.setNegativeButton("Annuler") { _, _ ->
|
||||
handled = true
|
||||
onDismiss()
|
||||
}
|
||||
.create()
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
if (!handled) onDismiss()
|
||||
}
|
||||
|
||||
dialog.setOnShowListener {
|
||||
editText.requestFocus()
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
editText.post {
|
||||
editText.requestFocus()
|
||||
editText.setSelection(editText.text?.length ?: 0)
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
|
||||
onDispose {
|
||||
if (dialog.isShowing) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
color = if (isSelected) Color.White else Color(0xFFAAAAAA),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.moveFocusOnDpad(
|
||||
up: (() -> Unit)? = null,
|
||||
down: (() -> Unit)? = null,
|
||||
left: (() -> Unit)? = null,
|
||||
right: (() -> Unit)? = null
|
||||
): Modifier = onPreviewKeyEvent { event ->
|
||||
// Important Android TV / Mi Box:
|
||||
// consume DPAD on KeyDown. If KeyDown is left to the system and KeyUp
|
||||
// also moves focus, one press can jump over the next field. Human input,
|
||||
// naturally, needed traffic control.
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
if (up != null) {
|
||||
up.invoke()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Key.DirectionDown -> {
|
||||
if (down != null) {
|
||||
down.invoke()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Key.DirectionLeft -> {
|
||||
if (left != null) {
|
||||
left.invoke()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
if (right != null) {
|
||||
right.invoke()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFFE50914),
|
||||
unfocusedBorderColor = Color(0xFF333333),
|
||||
focusedLabelColor = Color(0xFFE50914),
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = Color.White,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
@@ -0,0 +1,744 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.ContinueWatchingItem
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.MainMenuViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.RecentLiveItem
|
||||
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
viewModel: MainMenuViewModel = viewModel(),
|
||||
onOpenTv: () -> Unit,
|
||||
onOpenVod: () -> Unit,
|
||||
onOpenSeries: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onOpenYoutube: () -> Unit,
|
||||
onNavigate: (NavState) -> Unit = {}
|
||||
) {
|
||||
val activity = LocalContext.current as? Activity
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val tvFocusRequester = remember { FocusRequester() }
|
||||
val mainScrollState = rememberScrollState()
|
||||
val continueItemsState by viewModel.continueItems.collectAsStateWithLifecycle()
|
||||
val recentLiveItemsState by viewModel.recentLiveItems.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(180)
|
||||
try { tvFocusRequester.requestFocus() } catch (_: Exception) {}
|
||||
viewModel.refreshRecentLive()
|
||||
viewModel.refreshContinueWatching()
|
||||
}
|
||||
|
||||
BackHandler { showExitDialog = true }
|
||||
|
||||
if (showExitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { Text("Quitter iMedia TV+ ?", fontWeight = FontWeight.Bold, color = DesignColors.text) },
|
||||
text = { Text("Êtes-vous sûr de vouloir quitter l'application ?", color = DesignColors.muted) },
|
||||
containerColor = DesignColors.bg2,
|
||||
titleContentColor = DesignColors.text,
|
||||
textContentColor = DesignColors.muted,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showExitDialog = false; activity?.finishAffinity() }) {
|
||||
Text("Oui", color = DesignColors.orange)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitDialog = false }) {
|
||||
Text("Non", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
MainMenuSettingsDialog(
|
||||
onDismiss = { showSettings = false },
|
||||
onLogout = {
|
||||
showSettings = false
|
||||
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
|
||||
XtreamApplication.instance.appContainer.authRepository.logout()
|
||||
activity?.recreate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(DesignColors.bg3.copy(alpha = 0.45f), Color.Transparent),
|
||||
radius = 1200f
|
||||
)
|
||||
)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(DesignColors.blue.copy(alpha = 0.12f), Color.Transparent),
|
||||
radius = 1400f,
|
||||
center = androidx.compose.ui.geometry.Offset(1900f, 120f)
|
||||
)
|
||||
)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 20.dp, end = 28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MainMenuSettingsButton(onClick = { showSettings = true })
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(mainScrollState)
|
||||
.padding(horizontal = 44.dp, vertical = 34.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"iMedia TV+",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(DesignColors.orange, DesignColors.orange2)
|
||||
)
|
||||
),
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = (-0.08).sp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"Choisissez votre espace",
|
||||
color = DesignColors.muted,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MainMenuTile(
|
||||
modifier = Modifier.focusRequester(tvFocusRequester),
|
||||
icon = "TV",
|
||||
title = "TV",
|
||||
subtitle = "Chaînes en direct",
|
||||
accent = DesignColors.orange,
|
||||
onClick = onOpenTv
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
MainMenuTile(
|
||||
icon = "VOD",
|
||||
title = "VOD",
|
||||
subtitle = "Films à la demande",
|
||||
accent = DesignColors.red,
|
||||
onClick = onOpenVod
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
MainMenuTile(
|
||||
icon = "SER",
|
||||
title = "SÉRIES",
|
||||
subtitle = "Saisons et épisodes",
|
||||
accent = DesignColors.green,
|
||||
onClick = onOpenSeries
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
MainMenuTile(
|
||||
icon = "SRC",
|
||||
title = "RECHERCHE",
|
||||
subtitle = "LIVE, VOD et séries",
|
||||
accent = DesignColors.blue,
|
||||
onClick = onOpenSearch
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
MainMenuTile(
|
||||
icon = "YT",
|
||||
title = "YOUTUBE",
|
||||
subtitle = "Vidéos enregistrées",
|
||||
accent = DesignColors.red,
|
||||
onClick = onOpenYoutube
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(26.dp))
|
||||
|
||||
RecentLiveSection(
|
||||
itemsState = recentLiveItemsState,
|
||||
onSectionFocused = {},
|
||||
onPlay = { item ->
|
||||
viewModel.recordRecentLive(item.stream)
|
||||
onNavigate(
|
||||
NavState.Player(
|
||||
stream = item.stream,
|
||||
allStreams = listOf(item.stream),
|
||||
currentIndex = 0,
|
||||
replayUrl = null,
|
||||
returnState = NavState.MainMenu
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
ContinueWatchingSection(
|
||||
itemsState = continueItemsState,
|
||||
onSectionFocused = {},
|
||||
onPlay = { item ->
|
||||
onNavigate(
|
||||
NavState.Player(
|
||||
stream = item.stream,
|
||||
allStreams = item.allStreams,
|
||||
currentIndex = item.currentIndex,
|
||||
replayUrl = null,
|
||||
returnState = NavState.MainMenu,
|
||||
progressKey = item.progressKey,
|
||||
progressKeys = item.progressKeys
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
Text(
|
||||
"OK pour ouvrir • BACK pour quitter",
|
||||
color = DesignColors.muted2,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(42.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentLiveSection(
|
||||
itemsState: UiState<List<RecentLiveItem>>,
|
||||
onSectionFocused: () -> Unit = {},
|
||||
onPlay: (RecentLiveItem) -> Unit
|
||||
) {
|
||||
when (itemsState) {
|
||||
is UiState.Success -> {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Dernières chaînes LIVE",
|
||||
color = DesignColors.text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(start = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
if (itemsState.data.isEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(420.dp)
|
||||
.height(78.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = DesignColors.panel,
|
||||
border = BorderStroke(1.dp, DesignColors.stroke.copy(alpha = 0.55f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 18.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
"Aucune chaîne récente. Lancez une chaîne LIVE, puis revenez ici.",
|
||||
color = DesignColors.muted,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().height(112.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(itemsState.data, key = { it.id }) { item ->
|
||||
RecentLiveCard(
|
||||
item = item,
|
||||
onFocused = onSectionFocused,
|
||||
onPlay = { onPlay(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentLiveCard(
|
||||
item: RecentLiveItem,
|
||||
onFocused: () -> Unit = {},
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.height(104.dp)
|
||||
.onFocusChanged {
|
||||
val focused = it.isFocused || it.hasFocus
|
||||
if (focused && !isFocused) onFocused()
|
||||
isFocused = focused
|
||||
}
|
||||
.focusable()
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 1.dp,
|
||||
color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 76.dp, height = 82.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF151515)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!item.imageUrl.isNullOrBlank()) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = item.imageUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text("TV", color = DesignColors.orange, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
item.title,
|
||||
color = DesignColors.text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.subtitle,
|
||||
color = DesignColors.muted,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContinueWatchingSection(
|
||||
itemsState: UiState<List<ContinueWatchingItem>>,
|
||||
onSectionFocused: () -> Unit = {},
|
||||
onPlay: (ContinueWatchingItem) -> Unit
|
||||
) {
|
||||
when (itemsState) {
|
||||
UiState.Loading -> {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Continuer à regarder",
|
||||
color = DesignColors.text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(start = 10.dp, bottom = 6.dp)
|
||||
)
|
||||
Text(
|
||||
"Chargement des reprises…",
|
||||
color = DesignColors.muted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
is UiState.Success -> {
|
||||
if (itemsState.data.isEmpty()) return
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Continuer à regarder",
|
||||
color = DesignColors.text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(start = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().height(132.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(itemsState.data, key = { it.id }) { item ->
|
||||
ContinueWatchingCard(
|
||||
item = item,
|
||||
onFocused = onSectionFocused,
|
||||
onPlay = { onPlay(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
onFocused: () -> Unit = {},
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val fraction = item.progress.fraction.coerceIn(0f, 1f)
|
||||
|
||||
Surface(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.width(310.dp)
|
||||
.height(124.dp)
|
||||
.onFocusChanged {
|
||||
val focused = it.isFocused || it.hasFocus
|
||||
if (focused && !isFocused) onFocused()
|
||||
isFocused = focused
|
||||
}
|
||||
.focusable()
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 1.dp,
|
||||
color = if (isFocused) DesignColors.orange else DesignColors.stroke.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 86.dp, height = 96.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF151515)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!item.imageUrl.isNullOrBlank()) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = item.imageUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text(item.title.take(2).uppercase(), color = DesignColors.orange, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
item.title,
|
||||
color = DesignColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
item.subtitle,
|
||||
color = DesignColors.muted,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xFF444444))
|
||||
) {
|
||||
if (fraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(fraction)
|
||||
.background(DesignColors.orange)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuTile(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
accent: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val focusAlpha by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1f else 0f,
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.width(172.dp)
|
||||
.height(158.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.focusable()
|
||||
.border(
|
||||
width = if (isFocused) 4.dp else 1.dp,
|
||||
color = if (isFocused) accent.copy(alpha = focusAlpha) else DesignColors.strokeStrong.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(26.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = if (isFocused) {
|
||||
listOf(accent.copy(alpha = 0.34f), DesignColors.panel, DesignColors.panel2)
|
||||
} else {
|
||||
listOf(Color(0x1FFFFFFF), DesignColors.panel, DesignColors.panel2)
|
||||
}
|
||||
)
|
||||
)
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(listOf(accent, accent.copy(alpha = 0.55f))),
|
||||
RoundedCornerShape(20.dp)
|
||||
)
|
||||
.border(
|
||||
BorderStroke(1.dp, Color.White.copy(alpha = 0.22f)),
|
||||
RoundedCornerShape(20.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
icon,
|
||||
color = Color.White,
|
||||
fontSize = if (icon.length <= 2) 23.sp else 16.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(13.dp))
|
||||
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
color = if (isFocused) DesignColors.text else DesignColors.muted,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuSettingsButton(onClick: () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val focusAlpha by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1f else 0f,
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 1.dp,
|
||||
color = if (isFocused) DesignColors.orange.copy(alpha = focusAlpha) else DesignColors.stroke,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isFocused) Color(0x33FFFFFF) else Color(0x0DFFFFFF)
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("⚙", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuSettingsDialog(onDismiss: () -> Unit, onLogout: () -> Unit) {
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Paramètres", fontWeight = FontWeight.Bold, color = DesignColors.text) },
|
||||
text = {
|
||||
Column(modifier = Modifier.widthIn(max = 440.dp)) {
|
||||
if (config != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("Source : ", color = DesignColors.muted, fontSize = 14.sp)
|
||||
Text(
|
||||
if (config.sourceType == SourceType.XTREAM) "Xtream" else "M3U",
|
||||
color = DesignColors.orange,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (config.sourceType == SourceType.XTREAM) {
|
||||
MainMenuInfoRow("Serveur", config.serverUrl)
|
||||
MainMenuInfoRow("Utilisateur", config.username)
|
||||
} else {
|
||||
MainMenuInfoRow("URL M3U", config.m3uUrl)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Appuyez sur Déconnecter pour changer de source.",
|
||||
color = DesignColors.muted2,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = DesignColors.bg2,
|
||||
titleContentColor = DesignColors.text,
|
||||
textContentColor = DesignColors.muted,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onLogout(); onDismiss() }) {
|
||||
Text("Déconnecter", color = DesignColors.red)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Fermer", color = DesignColors.muted) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuInfoRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text("$label : ", color = DesignColors.muted, fontSize = 13.sp)
|
||||
Text(value, color = DesignColors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
|
||||
import timber.log.Timber
|
||||
|
||||
private const val VOD_SERIES_SEEK_STEP_MS = 10_000L
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
initialStream: LiveStreamDto,
|
||||
allStreams: List<LiveStreamDto>,
|
||||
initialIndex: Int,
|
||||
replayUrl: String? = null,
|
||||
progressKey: String? = null,
|
||||
progressKeys: List<String> = emptyList(),
|
||||
viewModel: PlayerViewModel = viewModel(),
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var player by remember { mutableStateOf<ExoPlayer?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
|
||||
val activeProgressKey = progressKeys.getOrNull(state.currentIndex) ?: progressKey
|
||||
val isVodOrSeries = activeProgressKey != null
|
||||
var currentPlayerProgressKey by remember { mutableStateOf<String?>(null) }
|
||||
var youtubeVisible by remember { mutableStateOf(false) }
|
||||
var isPlayerPaused by remember { mutableStateOf(false) }
|
||||
var currentPositionMs by remember { mutableStateOf(0L) }
|
||||
var currentDurationMs by remember { mutableStateOf(0L) }
|
||||
|
||||
fun saveProgressAtKey(key: String?, positionMs: Long, durationMs: Long) {
|
||||
if (key == null) return
|
||||
val keyIndex = progressKeys.indexOf(key)
|
||||
val metadataStream = if (keyIndex >= 0) {
|
||||
allStreams.getOrNull(keyIndex) ?: state.stream
|
||||
} else {
|
||||
state.stream
|
||||
}
|
||||
progressRepository.saveContinueMetadata(
|
||||
key = key,
|
||||
stream = metadataStream,
|
||||
// Ne pas sauvegarder toute la playlist d'une série toutes les 5 secondes :
|
||||
// cela ralentit les petites box TV. La reprise reprend l'épisode courant.
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
progressKeys = emptyList(),
|
||||
playbackUrl = replayUrl ?: metadataStream.directUrl.takeIf { it.isNotBlank() }
|
||||
)
|
||||
progressRepository.saveProgress(
|
||||
key = key,
|
||||
positionMs = positionMs,
|
||||
durationMs = normalizeDurationMs(durationMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun saveProgressAt(positionMs: Long, durationMs: Long) {
|
||||
saveProgressAtKey(currentPlayerProgressKey ?: activeProgressKey, positionMs, durationMs)
|
||||
}
|
||||
|
||||
fun saveCurrentProgress() {
|
||||
val currentPlayer = player ?: return
|
||||
saveProgressAt(
|
||||
positionMs = currentPlayer.currentPosition,
|
||||
durationMs = currentPlayer.duration
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(initialStream) {
|
||||
viewModel.initialize(initialStream, allStreams, initialIndex)
|
||||
}
|
||||
|
||||
// Use replay URL if provided, otherwise build live URL
|
||||
val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) {
|
||||
replayUrl ?: viewModel.buildUrl(state.stream)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
saveCurrentProgress()
|
||||
player?.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-hide overlay after 3 seconds
|
||||
LaunchedEffect(state.showOverlay) {
|
||||
if (state.showOverlay) {
|
||||
delay(3000)
|
||||
viewModel.hideOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when channel changes, with resolution listener
|
||||
LaunchedEffect(streamUrl, activeProgressKey) {
|
||||
if (streamUrl.isNotEmpty()) {
|
||||
val oldPlayer = player
|
||||
val oldProgressKey = currentPlayerProgressKey
|
||||
if (oldPlayer != null) {
|
||||
saveProgressAtKey(
|
||||
key = oldProgressKey,
|
||||
positionMs = oldPlayer.currentPosition,
|
||||
durationMs = oldPlayer.duration
|
||||
)
|
||||
oldPlayer.stop()
|
||||
oldPlayer.release()
|
||||
}
|
||||
|
||||
val resumePositionMs = activeProgressKey?.let { progressRepository.getProgress(it) } ?: 0L
|
||||
currentPlayerProgressKey = activeProgressKey
|
||||
currentPositionMs = resumePositionMs
|
||||
currentDurationMs = 0L
|
||||
|
||||
// User-Agent personnalisé pour les flux CDN/IPTV
|
||||
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("VLC/3.0.21 LibVLC/3.0.21")
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(httpDataSourceFactory)
|
||||
|
||||
var firstErrorShown = false
|
||||
|
||||
val newPlayer = ExoPlayer.Builder(context, DefaultRenderersFactory(context))
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.build().apply {
|
||||
volume = if (youtubeVisible) 0f else 1f
|
||||
Timber.i("▶ Playing stream URL: %s", streamUrl)
|
||||
setMediaItem(MediaItem.fromUri(streamUrl))
|
||||
if (resumePositionMs > 0L) {
|
||||
seekTo(resumePositionMs)
|
||||
}
|
||||
addListener(object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
val res = "${videoSize.width}x${videoSize.height}"
|
||||
viewModel.updateResolution(res)
|
||||
}
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
currentDurationMs = normalizeDurationMs(this@apply.duration)
|
||||
currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L)
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
viewModel.resetFreezeCounter()
|
||||
firstErrorShown = false
|
||||
}
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
val endedProgressKey = currentPlayerProgressKey ?: activeProgressKey
|
||||
endedProgressKey?.let { progressRepository.markCompleted(it, currentDurationMs) }
|
||||
currentPositionMs = 0L
|
||||
isPlayerPaused = false
|
||||
if (endedProgressKey?.startsWith("series:") == true) {
|
||||
viewModel.playNextItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
isPlayerPaused = !isPlaying && this@apply.playbackState != Player.STATE_ENDED
|
||||
if (isPlaying) {
|
||||
viewModel.resetFreezeCounter()
|
||||
}
|
||||
currentDurationMs = normalizeDurationMs(this@apply.duration)
|
||||
currentPositionMs = this@apply.currentPosition.coerceAtLeast(0L)
|
||||
}
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
if (!firstErrorShown) {
|
||||
firstErrorShown = true
|
||||
Timber.w("Player error on ${state.stream.name}: ${error.errorCodeName} — ${error.message}")
|
||||
}
|
||||
// Si le replay TV échoue, essayer le live en fallback
|
||||
if (replayUrl != null && activeProgressKey == null) {
|
||||
player?.stop()
|
||||
player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream)))
|
||||
player?.prepare()
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
})
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
player = newPlayer
|
||||
|
||||
// Freeze detection : attend 15s de grâce au début,
|
||||
// puis ne redémarre qu'après 10s de buffering continu
|
||||
val startTime = System.currentTimeMillis()
|
||||
while (isActive && viewModel.canRestart()) {
|
||||
delay(5000)
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
if (elapsed < 15_000) continue // grâce de 15s au démarrage
|
||||
val p = player
|
||||
if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
|
||||
delay(5000)
|
||||
if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
|
||||
viewModel.requestRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player, youtubeVisible) {
|
||||
player?.volume = if (youtubeVisible) 0f else 1f
|
||||
}
|
||||
|
||||
LaunchedEffect(player, activeProgressKey) {
|
||||
if (activeProgressKey == null) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
val currentPlayer = player
|
||||
if (currentPlayer != null) {
|
||||
currentPositionMs = currentPlayer.currentPosition.coerceAtLeast(0L)
|
||||
currentDurationMs = normalizeDurationMs(currentPlayer.duration)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player, activeProgressKey) {
|
||||
if (activeProgressKey == null) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
delay(5000)
|
||||
saveCurrentProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// Request focus for remote-control key events
|
||||
LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }
|
||||
|
||||
val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp) {
|
||||
if (youtubeVisible) {
|
||||
// IMPORTANT : quand le mini lecteur YouTube est ouvert,
|
||||
// le parent ne consomme plus aucune touche.
|
||||
// Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte.
|
||||
// Cela évite les différences de focus entre modèles de Mi Box.
|
||||
false
|
||||
} else {
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true }
|
||||
Key.DirectionLeft -> {
|
||||
if (isVodOrSeries) {
|
||||
player?.let { p ->
|
||||
val targetPosition = (p.currentPosition - VOD_SERIES_SEEK_STEP_MS).coerceAtLeast(0L)
|
||||
p.seekTo(targetPosition)
|
||||
val duration = normalizeDurationMs(p.duration)
|
||||
currentPositionMs = targetPosition
|
||||
currentDurationMs = duration
|
||||
saveProgressAt(targetPosition, duration)
|
||||
}
|
||||
viewModel.showOverlay()
|
||||
} else {
|
||||
viewModel.zappingLeft()
|
||||
}
|
||||
true
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
if (isVodOrSeries) {
|
||||
player?.let { p ->
|
||||
val targetPosition = p.currentPosition + VOD_SERIES_SEEK_STEP_MS
|
||||
val duration = normalizeDurationMs(p.duration)
|
||||
val finalPosition = if (duration > 0L) targetPosition.coerceAtMost(duration) else targetPosition
|
||||
p.seekTo(finalPosition)
|
||||
currentPositionMs = finalPosition
|
||||
currentDurationMs = duration
|
||||
saveProgressAt(finalPosition, duration)
|
||||
}
|
||||
viewModel.showOverlay()
|
||||
} else {
|
||||
viewModel.zappingRight()
|
||||
}
|
||||
true
|
||||
}
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
if (isVodOrSeries) {
|
||||
player?.let { p ->
|
||||
if (p.isPlaying) {
|
||||
p.pause()
|
||||
saveCurrentProgress()
|
||||
} else {
|
||||
p.play()
|
||||
}
|
||||
currentPositionMs = p.currentPosition.coerceAtLeast(0L)
|
||||
currentDurationMs = normalizeDurationMs(p.duration)
|
||||
isPlayerPaused = !p.isPlaying
|
||||
}
|
||||
viewModel.showOverlay()
|
||||
} else {
|
||||
viewModel.toggleOverlay()
|
||||
}
|
||||
true
|
||||
}
|
||||
Key.Back -> {
|
||||
if (state.showOverlay) {
|
||||
saveCurrentProgress()
|
||||
player?.stop()
|
||||
player?.release()
|
||||
player = null
|
||||
onBack()
|
||||
} else {
|
||||
viewModel.toggleOverlay()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
// Video player area
|
||||
if (!isLoadingStream) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
useController = false
|
||||
keepScreenOn = true
|
||||
}
|
||||
},
|
||||
update = { view -> view.player = player },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Chargement du flux…", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay bas : infos + avancement + raccourcis télécommande
|
||||
AnimatedVisibility(
|
||||
visible = state.showOverlay,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.78f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// Logo
|
||||
if (state.stream.streamIcon != null) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = state.stream.streamIcon,
|
||||
contentDescription = state.stream.name,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Nom de la chaîne / vidéo
|
||||
Text(
|
||||
state.stream.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Résolution
|
||||
if (state.resolution.isNotEmpty()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
if (isVodOrSeries && currentPositionMs > 0L) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PlaybackProgressInfo(
|
||||
positionMs = currentPositionMs,
|
||||
durationMs = currentDurationMs,
|
||||
accentColor = Color(0xFFE50914)
|
||||
)
|
||||
}
|
||||
|
||||
// Programme en cours
|
||||
if (state.currentProgram.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
|
||||
Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
// Programme suivant
|
||||
if (state.nextProgram.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
|
||||
Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVodOrSeries) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.14f))
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (isPlayerPaused) {
|
||||
Text("Pause", color = Color.White, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Text("OK : Pause / Reprendre", color = Color.LightGray)
|
||||
Text("◀ -10s", color = Color.LightGray)
|
||||
Text("+10s ▶", color = Color.LightGray)
|
||||
Text("Retour : menu", color = Color.LightGray)
|
||||
}
|
||||
} else if (allStreams.size > 1) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.14f))
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Text("▲ YouTube discret", color = Color.LightGray)
|
||||
Text("◀ Chaîne précédente", color = Color.LightGray)
|
||||
Text("Retour : menu", color = Color.LightGray)
|
||||
Text("Chaîne suivante ▶", color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (youtubeVisible) {
|
||||
YoutubeMiniPlayer(
|
||||
onClose = { youtubeVisible = false },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun PlaybackProgressInfo(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
accentColor: Color
|
||||
) {
|
||||
val fraction = if (durationMs > 0L) {
|
||||
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val percent = if (fraction > 0f) (fraction * 100f).toInt().coerceIn(1, 99) else 0
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Avancement", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (durationMs > 0L && percent > 0) {
|
||||
"${formatPlaybackTime(positionMs)} / ${formatPlaybackTime(durationMs)} • $percent%"
|
||||
} else {
|
||||
formatPlaybackTime(positionMs)
|
||||
},
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xFF444444))
|
||||
) {
|
||||
if (fraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(fraction)
|
||||
.background(accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeDurationMs(durationMs: Long): Long {
|
||||
return if (durationMs > 0L && durationMs < Long.MAX_VALUE / 2L) durationMs else 0L
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
|
||||
val hours = totalSeconds / 3600L
|
||||
val minutes = (totalSeconds % 3600L) / 60L
|
||||
val seconds = totalSeconds % 60L
|
||||
return if (hours > 0L) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun ProgramGuideScreen(
|
||||
viewModel: GuideViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val streamsState by viewModel.streams.collectAsStateWithLifecycle()
|
||||
val epgState by viewModel.epg.collectAsStateWithLifecycle()
|
||||
val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back") }
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text("TV Guide", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
when (val strs = streamsState) {
|
||||
is UiState.Success -> {
|
||||
val streamList = strs.data
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
items(streamList) { stream ->
|
||||
Surface(
|
||||
onClick = { viewModel.selectStream(stream.streamId) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cornerRadius = 10.dp
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(stream.name, style = MaterialTheme.typography.bodyLarge)
|
||||
if (stream.streamId == selectedStreamId) {
|
||||
when (val epg = epgState) {
|
||||
is UiState.Success -> {
|
||||
epg.data.take(5).forEach { program ->
|
||||
Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall)
|
||||
else -> {}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SuggestionChip(
|
||||
onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Live") }
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = { onNavigate(NavState.ReplayGuide(stream)) },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Replay") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel
|
||||
|
||||
@Composable
|
||||
fun ReplayGuideScreen(
|
||||
stream: LiveStreamDto,
|
||||
viewModel: ReplayViewModel = viewModel(),
|
||||
onBack: () -> Unit = {},
|
||||
onPlayReplay: (String) -> Unit = {}
|
||||
) {
|
||||
val epgState by viewModel.epg.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(stream) { viewModel.loadReplay(stream) }
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back") }
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
when (val epg = epgState) {
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) }
|
||||
is UiState.Success -> {
|
||||
val grouped = epg.data.groupBy { it.start?.take(10) ?: "" }
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
grouped.forEach { (day, programs) ->
|
||||
item {
|
||||
Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
items(programs) { program ->
|
||||
Surface(
|
||||
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cornerRadius = 10.dp
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
SuggestionChip(
|
||||
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Revoir") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
viewModel: SeriesViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
|
||||
val seriesState by viewModel.series.collectAsStateWithLifecycle()
|
||||
val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle()
|
||||
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
|
||||
var selectedSeries by remember { mutableStateOf<SeriesDto?>(null) }
|
||||
val seriesGridState = rememberLazyGridState()
|
||||
val episodesListState = rememberLazyListState()
|
||||
val categoryListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val seriesFocusRequesters = remember { mutableStateMapOf<Int, FocusRequester>() }
|
||||
|
||||
BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (selectedSeries != null) selectedSeries = null
|
||||
else onBack()
|
||||
},
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFF4CAF50),
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (selectedSeries != null) selectedSeries!!.name else "Series",
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50))
|
||||
}
|
||||
|
||||
// Categories (hidden in detail view)
|
||||
if (selectedSeries == null) {
|
||||
val catData = categoriesState
|
||||
if (catData is UiState.Success) {
|
||||
val catList = catData.data
|
||||
LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) {
|
||||
if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0)
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
state = categoryListState,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } }
|
||||
item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } }
|
||||
catList.forEach { cat ->
|
||||
item(key = cat.categoryId) {
|
||||
Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detail view
|
||||
val detailSeries = selectedSeries
|
||||
if (detailSeries != null) {
|
||||
val info = seriesInfoState
|
||||
when {
|
||||
info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) }
|
||||
info is UiState.Success -> {
|
||||
var lastContentRowIndex = 0
|
||||
info.data.seasons.forEach { season ->
|
||||
lastContentRowIndex += 1
|
||||
lastContentRowIndex += info.data.episodes[season.seasonNumber.toString()].orEmpty().size
|
||||
}
|
||||
val maxEpisodeRowIndex = (lastContentRowIndex - 1).coerceAtLeast(0)
|
||||
val playableEpisodes = info.data.seasons.flatMap { season ->
|
||||
info.data.episodes[season.seasonNumber.toString()].orEmpty()
|
||||
}
|
||||
val episodeProgressKeys = playableEpisodes.map { ep ->
|
||||
"series:${detailSeries.seriesId}:${ep.id}"
|
||||
}
|
||||
|
||||
fun scrollToEpisodeRow(index: Int) {
|
||||
val targetIndex = index.coerceIn(0, maxEpisodeRowIndex)
|
||||
coroutineScope.launch {
|
||||
episodesListState.animateScrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.tvFastScrollKeys(
|
||||
enabled = maxEpisodeRowIndex > 0,
|
||||
onJumpToTop = { scrollToEpisodeRow(0) },
|
||||
onJumpToBottom = { scrollToEpisodeRow(maxEpisodeRowIndex) },
|
||||
onPageUp = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex - 12) },
|
||||
onPageDown = { scrollToEpisodeRow(episodesListState.firstVisibleItemIndex + 12) }
|
||||
),
|
||||
state = episodesListState
|
||||
) {
|
||||
info.data.seasons.forEach { season ->
|
||||
item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) }
|
||||
val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList()
|
||||
items(episodes, key = { it.id }) { ep ->
|
||||
val episodeProgressKey = "series:${detailSeries.seriesId}:${ep.id}"
|
||||
val legacyEpisodeProgressKey = "series:${ep.id}"
|
||||
val savedProgress = progressRepository.getPlaybackProgress(episodeProgressKey, legacyEpisodeProgressKey)
|
||||
val progress = if (savedProgress.durationMs <= 0L) {
|
||||
savedProgress.copy(durationMs = parseEpisodeDurationMs(ep.info?.duration))
|
||||
} else {
|
||||
savedProgress
|
||||
}
|
||||
Surface(onClick = {
|
||||
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
if (config != null) {
|
||||
playableEpisodes.forEach { playlistEpisode ->
|
||||
progressRepository.migrateProgress(
|
||||
fromKey = "series:${playlistEpisode.id}",
|
||||
toKey = "series:${detailSeries.seriesId}:${playlistEpisode.id}"
|
||||
)
|
||||
}
|
||||
val urlBuilder = ma.tkgraph.xtreamtv.util.UrlBuilder(config)
|
||||
val episodeStreams = playableEpisodes.map { playlistEpisode ->
|
||||
ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
|
||||
name = episodePlayerTitle(detailSeries.name, playlistEpisode),
|
||||
streamId = playlistEpisode.id,
|
||||
streamIcon = playlistEpisode.info?.movieImage ?: detailSeries.cover,
|
||||
containerExtension = playlistEpisode.containerExtension,
|
||||
directUrl = urlBuilder.seriesUrl(playlistEpisode.id, playlistEpisode.containerExtension)
|
||||
)
|
||||
}
|
||||
val currentEpisodeIndex = playableEpisodes.indexOfFirst { it.id == ep.id }.coerceAtLeast(0)
|
||||
val selectedStream = episodeStreams.getOrNull(currentEpisodeIndex)
|
||||
if (selectedStream != null) {
|
||||
onNavigate(NavState.Player(
|
||||
stream = selectedStream,
|
||||
allStreams = episodeStreams,
|
||||
currentIndex = currentEpisodeIndex,
|
||||
replayUrl = null,
|
||||
returnState = NavState.Series(),
|
||||
progressKey = episodeProgressKeys.getOrNull(currentEpisodeIndex) ?: episodeProgressKey,
|
||||
progressKeys = episodeProgressKeys
|
||||
))
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFF4CAF50),
|
||||
cornerRadius = 10.dp
|
||||
), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
when {
|
||||
progress.isCompleted -> {
|
||||
EpisodeCompletedBadge(accentColor = Color(0xFF4CAF50))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
}
|
||||
progress.hasProgress -> {
|
||||
EpisodeProgressBadge(progress = progress, accentColor = Color(0xFF4CAF50))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Ep ${ep.episodeNum}: ${episodeDisplayTitle(ep)}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
if (progress.isCompleted || progress.hasProgress) {
|
||||
Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFF4CAF50), maxLines = 1)
|
||||
}
|
||||
}
|
||||
Text("\u25B6", color = Color(0xFF4CAF50))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { Box(Modifier.weight(1f)) } // Idle state
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Grid
|
||||
val s = seriesState
|
||||
when {
|
||||
s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) }
|
||||
s is UiState.Success -> {
|
||||
val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data
|
||||
|
||||
fun focusSeriesAt(index: Int) {
|
||||
val target = seriesList.getOrNull(index) ?: return
|
||||
try { seriesFocusRequesters[target.seriesId]?.requestFocus() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun scrollToSeriesIndex(index: Int) {
|
||||
if (seriesList.isEmpty()) return
|
||||
val targetIndex = index.coerceIn(0, seriesList.lastIndex)
|
||||
coroutineScope.launch {
|
||||
seriesGridState.animateScrollToItem(targetIndex)
|
||||
kotlinx.coroutines.delay(80)
|
||||
focusSeriesAt(targetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
state = seriesGridState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.tvFastScrollKeys(
|
||||
enabled = seriesList.isNotEmpty(),
|
||||
onJumpToTop = { scrollToSeriesIndex(0) },
|
||||
onJumpToBottom = { scrollToSeriesIndex(seriesList.lastIndex) },
|
||||
onPageUp = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex - 15) },
|
||||
onPageDown = { scrollToSeriesIndex(seriesGridState.firstVisibleItemIndex + 15) }
|
||||
),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(seriesList, key = { it.seriesId }) { series ->
|
||||
val seriesFocusRequester = remember(series.seriesId) {
|
||||
seriesFocusRequesters.getOrPut(series.seriesId) { FocusRequester() }
|
||||
}
|
||||
val hasSeriesProgress = progressRepository.hasAnyProgressWithPrefix("series:${series.seriesId}:")
|
||||
Surface(onClick = { viewModel.recordSeriesUsage(series.seriesId); selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.focusRequester(seriesFocusRequester).fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) {
|
||||
var favFocused by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Column {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
|
||||
if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)))
|
||||
else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold)
|
||||
if (hasSeriesProgress) {
|
||||
SeriesInProgressBadge(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(series.seriesId) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.onFocusChanged { favFocused = it.isFocused }
|
||||
.border(
|
||||
width = if (favFocused) 2.dp else 0.dp,
|
||||
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { Box(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val bgColor = when {
|
||||
isSelected -> accentColor
|
||||
isFocused -> Color(0xFF1A3A1A)
|
||||
else -> Color(0xFF252525)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = when {
|
||||
isFocused -> accentColor
|
||||
else -> Color.Transparent
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = bgColor
|
||||
) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SeriesInProgressBadge(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.74f))
|
||||
.padding(horizontal = 8.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xFF4CAF50))
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
"En cours de visionnage",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCompletedBadge(
|
||||
accentColor: Color
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(accentColor.copy(alpha = 0.26f))
|
||||
.border(1.dp, accentColor, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("✓", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Déjà vu", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeProgressBadge(
|
||||
progress: PlaybackProgressSnapshot,
|
||||
accentColor: Color
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(74.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xFF444444))
|
||||
) {
|
||||
if (progress.fraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(progress.fraction)
|
||||
.background(accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressLabel(progress: PlaybackProgressSnapshot): String {
|
||||
if (progress.isCompleted) return "Épisode terminé"
|
||||
val position = formatPlaybackTime(progress.positionMs)
|
||||
return if (progress.durationMs > 0L && progress.percent > 0) {
|
||||
"$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%"
|
||||
} else {
|
||||
"Reprise à $position"
|
||||
}
|
||||
}
|
||||
|
||||
private fun episodeDisplayTitle(ep: EpisodeDto): String {
|
||||
return ep.title ?: ep.name ?: ep.episodeName ?: ""
|
||||
}
|
||||
|
||||
private fun episodePlayerTitle(seriesName: String, ep: EpisodeDto): String {
|
||||
val title = episodeDisplayTitle(ep)
|
||||
return if (title.isBlank()) {
|
||||
"$seriesName - Ep ${ep.episodeNum}"
|
||||
} else {
|
||||
"$seriesName - Ep ${ep.episodeNum}: $title"
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEpisodeDurationMs(duration: String?): Long {
|
||||
if (duration.isNullOrBlank()) return 0L
|
||||
val trimmed = duration.trim()
|
||||
val parts = trimmed.split(":").mapNotNull { it.toLongOrNull() }
|
||||
if (parts.isEmpty()) return 0L
|
||||
val seconds = when (parts.size) {
|
||||
3 -> parts[0] * 3600L + parts[1] * 60L + parts[2]
|
||||
2 -> parts[0] * 60L + parts[1]
|
||||
1 -> parts[0] * 60L
|
||||
else -> 0L
|
||||
}
|
||||
return seconds.coerceAtLeast(0L) * 1000L
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
|
||||
val hours = totalSeconds / 3600L
|
||||
val minutes = (totalSeconds % 3600L) / 60L
|
||||
val seconds = totalSeconds % 60L
|
||||
return if (hours > 0L) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFastScrollKeys
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun VodScreen(
|
||||
viewModel: VodViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
|
||||
val moviesState by viewModel.movies.collectAsStateWithLifecycle()
|
||||
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val progressRepository = remember(context) { PlaybackProgressRepository(context) }
|
||||
val gridState = rememberLazyGridState()
|
||||
val categoryListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val movieFocusRequesters = remember { mutableStateMapOf<Int, FocusRequester>() }
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFFE50914),
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914))
|
||||
}
|
||||
|
||||
// Categories with ★ Favorites
|
||||
val catsState = categoriesState
|
||||
when (catsState) {
|
||||
is UiState.Success -> {
|
||||
val catList = catsState.data
|
||||
LaunchedEffect(catList.joinToString(separator = "|") { it.categoryId }) {
|
||||
if (catList.isNotEmpty()) categoryListState.animateScrollToItem(0)
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
state = categoryListState,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } }
|
||||
item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } }
|
||||
catList.forEach { cat ->
|
||||
item(key = cat.categoryId) {
|
||||
CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// Movies grid with posters
|
||||
when (val movies = moviesState) {
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) }
|
||||
is UiState.Success -> {
|
||||
val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data
|
||||
|
||||
fun focusMovieAt(index: Int) {
|
||||
val target = movieList.getOrNull(index) ?: return
|
||||
try { movieFocusRequesters[target.streamId]?.requestFocus() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun scrollToMovieIndex(index: Int) {
|
||||
if (movieList.isEmpty()) return
|
||||
val targetIndex = index.coerceIn(0, movieList.lastIndex)
|
||||
coroutineScope.launch {
|
||||
gridState.animateScrollToItem(targetIndex)
|
||||
kotlinx.coroutines.delay(80)
|
||||
focusMovieAt(targetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.tvFastScrollKeys(
|
||||
enabled = movieList.isNotEmpty(),
|
||||
onJumpToTop = { scrollToMovieIndex(0) },
|
||||
onJumpToBottom = { scrollToMovieIndex(movieList.lastIndex) },
|
||||
onPageUp = { scrollToMovieIndex(gridState.firstVisibleItemIndex - 15) },
|
||||
onPageDown = { scrollToMovieIndex(gridState.firstVisibleItemIndex + 15) }
|
||||
),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movieList, key = { it.streamId }) { movie ->
|
||||
val movieFocusRequester = remember(movie.streamId) {
|
||||
movieFocusRequesters.getOrPut(movie.streamId) { FocusRequester() }
|
||||
}
|
||||
val progress = progressRepository.getPlaybackProgress("vod:${movie.streamId}")
|
||||
MovieCard(
|
||||
modifier = Modifier.focusRequester(movieFocusRequester),
|
||||
name = movie.name,
|
||||
posterUrl = movie.streamIcon,
|
||||
rating = movie.rating,
|
||||
progress = progress,
|
||||
isFavorite = movie.streamId in favoriteIds,
|
||||
onToggleFav = { viewModel.toggleFavorite(movie.streamId) },
|
||||
onPlay = {
|
||||
viewModel.recordMovieUsage(movie.streamId)
|
||||
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
if (config != null) {
|
||||
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension)
|
||||
onNavigate(NavState.Player(
|
||||
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
|
||||
name = movie.name,
|
||||
streamId = movie.streamId,
|
||||
streamIcon = movie.streamIcon,
|
||||
containerExtension = movie.containerExtension
|
||||
),
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
replayUrl = url,
|
||||
returnState = NavState.Vod(),
|
||||
progressKey = "vod:${movie.streamId}"
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val bgColor = when {
|
||||
isSelected -> Color(0xFFE50914)
|
||||
isFocused -> Color(0xFF3A1A1A)
|
||||
else -> Color(0xFF252525)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = when {
|
||||
isFocused -> Color(0xFFE50914)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = bgColor
|
||||
) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MovieCard(
|
||||
modifier: Modifier = Modifier,
|
||||
name: String,
|
||||
posterUrl: String?,
|
||||
rating: String?,
|
||||
progress: PlaybackProgressSnapshot,
|
||||
isFavorite: Boolean,
|
||||
onToggleFav: () -> Unit,
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
var favFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onPlay,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Box {
|
||||
Column {
|
||||
// Poster
|
||||
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
|
||||
if (posterUrl != null) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
)
|
||||
} else {
|
||||
Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
if (progress.hasProgress) {
|
||||
ProgressOverlay(
|
||||
progress = progress,
|
||||
accentColor = Color(0xFFE50914),
|
||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
// Info bar
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA))
|
||||
if (progress.hasProgress) {
|
||||
Text(progressLabel(progress), style = MaterialTheme.typography.labelSmall, color = Color(0xFFE50914), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Favorite icon in top-right corner (doesn't steal card focus)
|
||||
IconButton(
|
||||
onClick = onToggleFav,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.onFocusChanged { favFocused = it.isFocused }
|
||||
.border(
|
||||
width = if (favFocused) 2.dp else 0.dp,
|
||||
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ProgressOverlay(
|
||||
progress: PlaybackProgressSnapshot,
|
||||
accentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.72f))
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xFF444444))
|
||||
) {
|
||||
if (progress.fraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(progress.fraction)
|
||||
.background(accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(progressLabel(progress), color = Color.White, fontSize = 11.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressLabel(progress: PlaybackProgressSnapshot): String {
|
||||
val position = formatPlaybackTime(progress.positionMs)
|
||||
return if (progress.durationMs > 0L && progress.percent > 0) {
|
||||
"$position / ${formatPlaybackTime(progress.durationMs)} • ${progress.percent}%"
|
||||
} else {
|
||||
"Reprise à $position"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
|
||||
val hours = totalSeconds / 3600L
|
||||
val minutes = (totalSeconds % 3600L) / 60L
|
||||
val seconds = totalSeconds % 60L
|
||||
return if (hours > 0L) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem
|
||||
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.YoutubeViewModel
|
||||
import ma.tkgraph.xtreamtv.util.YoutubeUrlUtils
|
||||
|
||||
@Composable
|
||||
fun YoutubeScreen(
|
||||
viewModel: YoutubeViewModel = viewModel(),
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val videos by viewModel.videos.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
var titleInput by remember { mutableStateOf("") }
|
||||
var linkInput by remember { mutableStateOf(YoutubeUrlUtils.DEFAULT_WATCH_PREFIX) }
|
||||
var playingVideo by remember { mutableStateOf<YoutubeVideoItem?>(null) }
|
||||
val linkFocusRequester = remember { FocusRequester() }
|
||||
|
||||
BackHandler {
|
||||
if (playingVideo != null) {
|
||||
playingVideo = null
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
if (playingVideo != null) {
|
||||
YoutubeFullPlayer(
|
||||
video = playingVideo!!,
|
||||
onClose = { playingVideo = null }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { linkFocusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 42.dp, vertical = 28.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"YouTube",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
brush = Brush.linearGradient(listOf(DesignColors.red, DesignColors.orange2))
|
||||
),
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
Text(
|
||||
"Ajoutez des liens ou codes YouTube, puis lancez-les depuis la TV",
|
||||
color = DesignColors.muted,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Retour", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
YoutubeAddPanel(
|
||||
titleInput = titleInput,
|
||||
onTitleChange = { titleInput = it },
|
||||
linkInput = linkInput,
|
||||
onLinkChange = { linkInput = it },
|
||||
linkFocusRequester = linkFocusRequester,
|
||||
message = message,
|
||||
onAdd = {
|
||||
val added = viewModel.addVideo(titleInput, linkInput)
|
||||
if (added) {
|
||||
titleInput = ""
|
||||
linkInput = YoutubeUrlUtils.DEFAULT_WATCH_PREFIX
|
||||
}
|
||||
},
|
||||
onClearMessage = { viewModel.clearMessage() }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
Text(
|
||||
"Vidéos enregistrées",
|
||||
color = DesignColors.text,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
if (videos.isEmpty()) {
|
||||
YoutubeEmptyState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(videos, key = { it.id }) { video ->
|
||||
YoutubeVideoRow(
|
||||
video = video,
|
||||
onPlay = {
|
||||
viewModel.markPlayed(video.id)
|
||||
playingVideo = video
|
||||
},
|
||||
onDelete = { viewModel.deleteVideo(video.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"OK pour lire • BACK pour revenir • Certains créateurs bloquent l'embed YouTube",
|
||||
color = DesignColors.muted2,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YoutubeAddPanel(
|
||||
titleInput: String,
|
||||
onTitleChange: (String) -> Unit,
|
||||
linkInput: String,
|
||||
onLinkChange: (String) -> Unit,
|
||||
linkFocusRequester: FocusRequester,
|
||||
message: String?,
|
||||
onAdd: () -> Unit,
|
||||
onClearMessage: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = DesignColors.panel,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
border = BorderStroke(1.dp, DesignColors.strokeStrong.copy(alpha = 0.35f))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = linkInput,
|
||||
onValueChange = onLinkChange,
|
||||
modifier = Modifier
|
||||
.weight(1.5f)
|
||||
.focusRequester(linkFocusRequester),
|
||||
singleLine = true,
|
||||
label = { Text("Lien ou code YouTube") },
|
||||
placeholder = { Text("https://www.youtube.com/watch?v=...") }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = titleInput,
|
||||
onValueChange = onTitleChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
label = { Text("Titre optionnel") },
|
||||
placeholder = { Text("Clip, documentaire...") }
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onAdd,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
|
||||
) {
|
||||
Text("Ajouter")
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = message,
|
||||
color = if (message.contains("impossible", ignoreCase = true) || message.contains("invalide", ignoreCase = true)) DesignColors.red else DesignColors.green,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClearMessage) {
|
||||
Text("OK", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YoutubeEmptyState() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(190.dp)
|
||||
.background(DesignColors.panel, RoundedCornerShape(20.dp))
|
||||
.border(1.dp, DesignColors.stroke, RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Aucune vidéo YouTube enregistrée", color = DesignColors.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Collez un lien ou juste le code vidéo, puis appuyez sur Ajouter.", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YoutubeVideoRow(
|
||||
video: YoutubeVideoItem,
|
||||
onPlay: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(118.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.focusable()
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 1.dp,
|
||||
color = if (isFocused) DesignColors.red else DesignColors.stroke.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (isFocused) Color(0x22FFFFFF) else DesignColors.panel
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 154.dp, height = 88.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!video.thumbnailUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = video.thumbnailUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text("YT", color = DesignColors.red, fontWeight = FontWeight.Black, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
video.title,
|
||||
color = DesignColors.text,
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
video.url,
|
||||
color = DesignColors.muted,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
if (video.lastPlayedAtMs > 0L) "Dernière lecture enregistrée" else "Jamais lu",
|
||||
color = DesignColors.muted2,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = onPlay,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
|
||||
) {
|
||||
Text("Lire")
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
TextButton(onClick = onDelete) {
|
||||
Text("Supprimer", color = DesignColors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun YoutubeFullPlayer(
|
||||
video: YoutubeVideoItem,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val target = remember(video.url) { YoutubeUrlUtils.parse(video.url) }
|
||||
var status by remember(video.id) { mutableStateOf("Chargement YouTube") }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
BackHandler { onClose() }
|
||||
|
||||
fun sendCommand(js: String) {
|
||||
webView?.evaluateJavascript(js, null)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Back -> {
|
||||
onClose()
|
||||
true
|
||||
}
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
sendCommand("if(window.togglePlay){togglePlay();}")
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft -> {
|
||||
sendCommand("if(window.seekBy){seekBy(-10);}")
|
||||
true
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
sendCommand("if(window.seekBy){seekBy(10);}")
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
if (target == null) {
|
||||
Text(
|
||||
"Lien YouTube invalide",
|
||||
color = DesignColors.text,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.databaseEnabled = true
|
||||
settings.mediaPlaybackRequiresUserGesture = false
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||
settings.setSupportMultipleWindows(false)
|
||||
settings.userAgentString = YoutubeUrlUtils.DESKTOP_USER_AGENT
|
||||
|
||||
runCatching {
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
val message = consoleMessage?.message().orEmpty()
|
||||
if (message.startsWith("IMEDIA_YT:")) {
|
||||
status = message.removePrefix("IMEDIA_YT:")
|
||||
return true
|
||||
}
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean = false
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if (!status.startsWith("YouTube erreur")) {
|
||||
status = "Player chargé : OK Play/Pause, ←/→ -10s/+10s"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
status = "Erreur WebView : ${error?.description ?: "chargement impossible"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
setBackgroundColor(android.graphics.Color.BLACK)
|
||||
}.also { webView = it }
|
||||
},
|
||||
update = { view ->
|
||||
webView = view
|
||||
if (view.tag != video.id) {
|
||||
view.tag = video.id
|
||||
if (target.canUseEmbed) {
|
||||
view.loadDataWithBaseURL(
|
||||
YoutubeUrlUtils.WEBVIEW_ORIGIN,
|
||||
YoutubeUrlUtils.buildIframeHtml(target),
|
||||
"text/html",
|
||||
"utf-8",
|
||||
target.toEmbedUrl()
|
||||
)
|
||||
} else {
|
||||
view.loadUrl(target.toWatchOrNormalizedUrl(), YoutubeUrlUtils.youtubeHeaders())
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.height(78.dp),
|
||||
color = Color.Black.copy(alpha = 0.58f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(video.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(status, color = Color(0xFFB8C7D1), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Button(onClick = { sendCommand("if(window.seekBy){seekBy(-10);}") }) { Text("-10s") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { sendCommand("if(window.togglePlay){togglePlay();}") }) { Text("Play/Pause") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { sendCommand("if(window.seekBy){seekBy(10);}") }) { Text("+10s") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = onClose,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DesignColors.red)
|
||||
) { Text("Fermer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package ma.tkgraph.xtreamtv.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ─── Couleurs du design iMedia TV+ ───
|
||||
object DesignColors {
|
||||
val bg1 = Color(0xFF020817)
|
||||
val bg2 = Color(0xFF071a3d)
|
||||
val bg3 = Color(0xFF2b0f53)
|
||||
val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74)
|
||||
val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70)
|
||||
val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12)
|
||||
val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42)
|
||||
val text = Color(0xFFF7F8FF)
|
||||
val muted = Color(0xFFA9B2D8)
|
||||
val muted2 = Color(0xFF7F8AB6)
|
||||
val orange = Color(0xFFFF512F)
|
||||
val orange2 = Color(0xFFFFB000)
|
||||
val blue = Color(0xFF1469FF)
|
||||
val blue2 = Color(0xFF09B7FF)
|
||||
val red = Color(0xFFFF3D64)
|
||||
val green = Color(0xFF00D9B2)
|
||||
val purple = Color(0xFF875CFF)
|
||||
val liveRed = Color(0xFFFE3152)
|
||||
val liveRedDark = Color(0xFFBA1733)
|
||||
}
|
||||
|
||||
private val DesignColorScheme = darkColorScheme(
|
||||
primary = DesignColors.orange,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = DesignColors.orange.copy(alpha = 0.2f),
|
||||
secondary = DesignColors.blue,
|
||||
onSecondary = Color.White,
|
||||
surface = Color(0xBD1A1C49),
|
||||
surfaceVariant = Color(0x70121D49),
|
||||
onSurface = DesignColors.text,
|
||||
onSurfaceVariant = DesignColors.muted,
|
||||
background = DesignColors.bg1,
|
||||
onBackground = DesignColors.text,
|
||||
error = DesignColors.red,
|
||||
onError = Color.White,
|
||||
outline = DesignColors.stroke
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun XtreamTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DesignColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import ma.tkgraph.xtreamtv.util.UrlBuilder
|
||||
|
||||
class GlobalSearchViewModel : ViewModel() {
|
||||
|
||||
private val container = XtreamApplication.instance.appContainer
|
||||
private var searchJob: Job? = null
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
private val _scope = MutableStateFlow(SearchScope.Live)
|
||||
val scope: StateFlow<SearchScope> = _scope.asStateFlow()
|
||||
|
||||
private val _results = MutableStateFlow<UiState<List<GlobalSearchResult>>>(UiState.Success(emptyList()))
|
||||
val results: StateFlow<UiState<List<GlobalSearchResult>>> = _results.asStateFlow()
|
||||
|
||||
fun setQuery(value: String) {
|
||||
_query.value = value
|
||||
}
|
||||
|
||||
fun setScope(value: SearchScope) {
|
||||
_scope.value = value
|
||||
_results.value = UiState.Success(emptyList())
|
||||
}
|
||||
|
||||
fun runSearch() {
|
||||
val rawQuery = _query.value.trim()
|
||||
if (rawQuery.normalizeForSearch().length < 2) {
|
||||
_results.value = UiState.Success(emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
val selectedScope = _scope.value
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
_results.value = UiState.Loading
|
||||
val accumulated = mutableListOf<GlobalSearchResult>()
|
||||
|
||||
if (selectedScope.includesLive) {
|
||||
val live = getLiveCatalog()
|
||||
accumulated += filterLive(live, rawQuery)
|
||||
if (accumulated.isNotEmpty()) {
|
||||
_results.value = UiState.Success(accumulated.toList())
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedScope.includesVod) {
|
||||
val vod = getVodCatalog()
|
||||
accumulated += filterVod(vod, rawQuery)
|
||||
if (accumulated.isNotEmpty()) {
|
||||
_results.value = UiState.Success(accumulated.toList())
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedScope.includesSeries) {
|
||||
val series = getSeriesCatalog()
|
||||
accumulated += filterSeries(series, rawQuery)
|
||||
if (accumulated.isNotEmpty()) {
|
||||
_results.value = UiState.Success(accumulated.toList())
|
||||
}
|
||||
}
|
||||
|
||||
_results.value = UiState.Success(accumulated.toList())
|
||||
}
|
||||
}
|
||||
|
||||
fun recordLiveUsage(stream: LiveStreamDto) {
|
||||
container.usageRepository.recordLiveStream(stream.streamId)
|
||||
container.recentLiveRepository.record(stream)
|
||||
}
|
||||
|
||||
fun recordLiveUsage(streamId: Int) {
|
||||
container.usageRepository.recordLiveStream(streamId)
|
||||
}
|
||||
|
||||
fun recordVodUsage(streamId: Int) {
|
||||
container.usageRepository.recordVodItem(streamId)
|
||||
}
|
||||
|
||||
fun recordSeriesUsage(seriesId: Int) {
|
||||
container.usageRepository.recordSeriesItem(seriesId)
|
||||
}
|
||||
|
||||
fun buildVodStream(movie: VodDto): LiveStreamDto? {
|
||||
val config = container.secureStorage.getCredentials() ?: return null
|
||||
val urlBuilder = UrlBuilder(config)
|
||||
return LiveStreamDto(
|
||||
name = movie.name,
|
||||
streamId = movie.streamId,
|
||||
streamIcon = movie.streamIcon,
|
||||
containerExtension = movie.containerExtension,
|
||||
directUrl = urlBuilder.vodUrl(movie.streamId, movie.containerExtension)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun buildSeriesLaunch(series: SeriesDto): SeriesLaunchData? {
|
||||
val config = container.secureStorage.getCredentials() ?: return null
|
||||
val urlBuilder = UrlBuilder(config)
|
||||
val info = when (val result = container.seriesRepository.getSeriesInfo(series.seriesId)) {
|
||||
is UiState.Success -> result.data
|
||||
else -> return null
|
||||
}
|
||||
val playableEpisodes = info.seasons.flatMap { season ->
|
||||
info.episodes[season.seasonNumber.toString()].orEmpty()
|
||||
}
|
||||
if (playableEpisodes.isEmpty()) return null
|
||||
val streams = playableEpisodes.map { episode ->
|
||||
LiveStreamDto(
|
||||
name = episodePlayerTitle(series.name, episode),
|
||||
streamId = episode.id,
|
||||
streamIcon = episode.info?.movieImage ?: series.cover,
|
||||
containerExtension = episode.containerExtension,
|
||||
directUrl = urlBuilder.seriesUrl(episode.id, episode.containerExtension)
|
||||
)
|
||||
}
|
||||
val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" }
|
||||
return SeriesLaunchData(
|
||||
stream = streams.first(),
|
||||
allStreams = streams,
|
||||
currentIndex = 0,
|
||||
progressKey = progressKeys.firstOrNull(),
|
||||
progressKeys = progressKeys
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getLiveCatalog(): List<LiveStreamDto> {
|
||||
cachedLiveStreams?.let { return it }
|
||||
val loaded = when (val result = container.liveRepository.getStreams(null)) {
|
||||
is UiState.Success -> result.data
|
||||
else -> emptyList()
|
||||
}
|
||||
cachedLiveStreams = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private suspend fun getVodCatalog(): List<VodDto> {
|
||||
cachedVodItems?.let { return it }
|
||||
val loaded = when (val result = container.vodRepository.getMovies(null)) {
|
||||
is UiState.Success -> result.data
|
||||
else -> emptyList()
|
||||
}
|
||||
cachedVodItems = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private suspend fun getSeriesCatalog(): List<SeriesDto> {
|
||||
cachedSeriesItems?.let { return it }
|
||||
val loaded = when (val result = container.seriesRepository.getSeriesList(null)) {
|
||||
is UiState.Success -> result.data
|
||||
else -> emptyList()
|
||||
}
|
||||
cachedSeriesItems = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private fun filterLive(items: List<LiveStreamDto>, rawQuery: String): List<GlobalSearchResult> {
|
||||
val normalizedQuery = rawQuery.normalizeForSearch()
|
||||
return items.asSequence()
|
||||
.filter { it.name.normalizeForSearch().contains(normalizedQuery) }
|
||||
.take(PER_TYPE_LIMIT)
|
||||
.map {
|
||||
GlobalSearchResult(
|
||||
id = "live:${it.streamId}",
|
||||
type = GlobalSearchResultType.Live,
|
||||
title = it.name,
|
||||
subtitle = "LIVE • Chaîne TV",
|
||||
imageUrl = it.streamIcon,
|
||||
liveStream = it
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun filterVod(items: List<VodDto>, rawQuery: String): List<GlobalSearchResult> {
|
||||
val normalizedQuery = rawQuery.normalizeForSearch()
|
||||
return items.asSequence()
|
||||
.filter { item ->
|
||||
item.name.normalizeForSearch().contains(normalizedQuery) ||
|
||||
item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) ||
|
||||
item.year.orEmpty().contains(rawQuery)
|
||||
}
|
||||
.take(PER_TYPE_LIMIT)
|
||||
.map {
|
||||
GlobalSearchResult(
|
||||
id = "vod:${it.streamId}",
|
||||
type = GlobalSearchResultType.Vod,
|
||||
title = it.name,
|
||||
subtitle = listOfNotNull("VOD", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "),
|
||||
imageUrl = it.streamIcon,
|
||||
vod = it
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun filterSeries(items: List<SeriesDto>, rawQuery: String): List<GlobalSearchResult> {
|
||||
val normalizedQuery = rawQuery.normalizeForSearch()
|
||||
return items.asSequence()
|
||||
.filter { item ->
|
||||
item.name.normalizeForSearch().contains(normalizedQuery) ||
|
||||
item.genre.orEmpty().normalizeForSearch().contains(normalizedQuery) ||
|
||||
item.year.orEmpty().contains(rawQuery)
|
||||
}
|
||||
.take(PER_TYPE_LIMIT)
|
||||
.map {
|
||||
GlobalSearchResult(
|
||||
id = "series:${it.seriesId}",
|
||||
type = GlobalSearchResultType.Series,
|
||||
title = it.name,
|
||||
subtitle = listOfNotNull("SÉRIE", it.year?.takeIf { year -> year.isNotBlank() }, it.genre?.takeIf { genre -> genre.isNotBlank() }).joinToString(" • "),
|
||||
imageUrl = it.cover,
|
||||
series = it
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PER_TYPE_LIMIT = 30
|
||||
private var cachedLiveStreams: List<LiveStreamDto>? = null
|
||||
private var cachedVodItems: List<VodDto>? = null
|
||||
private var cachedSeriesItems: List<SeriesDto>? = null
|
||||
}
|
||||
}
|
||||
|
||||
enum class SearchScope(val label: String) {
|
||||
Live("LIVE"),
|
||||
Vod("VOD"),
|
||||
Series("SÉRIES"),
|
||||
All("ALL");
|
||||
|
||||
val includesLive: Boolean get() = this == Live || this == All
|
||||
val includesVod: Boolean get() = this == Vod || this == All
|
||||
val includesSeries: Boolean get() = this == Series || this == All
|
||||
}
|
||||
|
||||
data class GlobalSearchResult(
|
||||
val id: String,
|
||||
val type: GlobalSearchResultType,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val imageUrl: String?,
|
||||
val liveStream: LiveStreamDto? = null,
|
||||
val vod: VodDto? = null,
|
||||
val series: SeriesDto? = null
|
||||
)
|
||||
|
||||
enum class GlobalSearchResultType { Live, Vod, Series }
|
||||
|
||||
data class SeriesLaunchData(
|
||||
val stream: LiveStreamDto,
|
||||
val allStreams: List<LiveStreamDto>,
|
||||
val currentIndex: Int,
|
||||
val progressKey: String?,
|
||||
val progressKeys: List<String>
|
||||
)
|
||||
|
||||
private fun String.normalizeForSearch(): String = trim().lowercase()
|
||||
.replace("é", "e")
|
||||
.replace("è", "e")
|
||||
.replace("ê", "e")
|
||||
.replace("ë", "e")
|
||||
.replace("à", "a")
|
||||
.replace("â", "a")
|
||||
.replace("î", "i")
|
||||
.replace("ï", "i")
|
||||
.replace("ô", "o")
|
||||
.replace("ö", "o")
|
||||
.replace("ù", "u")
|
||||
.replace("û", "u")
|
||||
.replace("ü", "u")
|
||||
.replace("ç", "c")
|
||||
|
||||
private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: ""
|
||||
|
||||
private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String {
|
||||
val title = episode.displayTitle()
|
||||
return if (title.isBlank()) "$seriesName - Ep ${episode.episodeNum}"
|
||||
else "$seriesName - Ep ${episode.episodeNum}: $title"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class GuideViewModel : ViewModel() {
|
||||
|
||||
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
|
||||
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(UiState.Success(emptyList()))
|
||||
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
|
||||
|
||||
private val _selectedStreamId = MutableStateFlow<Int?>(null)
|
||||
val selectedStreamId: StateFlow<Int?> = _selectedStreamId.asStateFlow()
|
||||
|
||||
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
|
||||
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
|
||||
|
||||
init { loadStreams() }
|
||||
|
||||
fun loadStreams() {
|
||||
viewModelScope.launch {
|
||||
val r = liveRepo.getStreams()
|
||||
if (r is UiState.Success) _streams.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun selectStream(streamId: Int) {
|
||||
_selectedStreamId.value = streamId
|
||||
viewModelScope.launch {
|
||||
val r = epgRepo.getFullGuide(streamId)
|
||||
if (r is UiState.Success) _epg.value = r
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
|
||||
private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<LiveCategoryDto>>>(
|
||||
UiState.Success(emptyList())
|
||||
)
|
||||
val categories: StateFlow<UiState<List<LiveCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(
|
||||
UiState.Success(emptyList())
|
||||
)
|
||||
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
// État réactif pour les favoris et l'épinglage
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId())
|
||||
val pinnedChannelId: StateFlow<Int?> = _pinnedChannelId.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadStreams()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val result = liveRepo.getCategories()
|
||||
if (result is UiState.Success) {
|
||||
_categories.value = UiState.Success(sortCategories(result.data))
|
||||
} else if (result is UiState.Error) {
|
||||
_categories.value = UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
usageRepo.recordLiveCategory(categoryId)
|
||||
_selectedCategoryId.value = categoryId
|
||||
refreshCategoryOrder()
|
||||
if (categoryId == -1) {
|
||||
// ★ Favorites : charger toutes les chaînes, filtrer dans l'UI
|
||||
loadStreams(null)
|
||||
} else {
|
||||
loadStreams(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordChannelUsage(stream: LiveStreamDto) {
|
||||
usageRepo.recordLiveStream(stream.streamId)
|
||||
recentLiveRepo.record(stream)
|
||||
refreshStreamOrder()
|
||||
}
|
||||
|
||||
fun recordChannelUsage(streamId: Int) {
|
||||
usageRepo.recordLiveStream(streamId)
|
||||
refreshStreamOrder()
|
||||
}
|
||||
|
||||
fun loadStreams(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val result = liveRepo.getStreams(categoryId)
|
||||
if (result is UiState.Success) {
|
||||
_streams.value = UiState.Success(sortStreams(result.data))
|
||||
} else if (result is UiState.Error) {
|
||||
_streams.value = UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(streamId: Int) {
|
||||
favRepo.toggleFavoriteLive(streamId)
|
||||
_favoriteIds.value = favRepo.getFavoriteLiveIds()
|
||||
}
|
||||
|
||||
fun togglePin(streamId: Int) {
|
||||
val current = _pinnedChannelId.value
|
||||
if (current == streamId) {
|
||||
favRepo.setPinnedChannelId(null)
|
||||
_pinnedChannelId.value = null
|
||||
} else {
|
||||
favRepo.setPinnedChannelId(streamId)
|
||||
_pinnedChannelId.value = streamId
|
||||
}
|
||||
}
|
||||
|
||||
/** Déconnexion : efface config + cache M3U. */
|
||||
fun logout() {
|
||||
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
|
||||
XtreamApplication.instance.appContainer.authRepository.logout()
|
||||
}
|
||||
|
||||
private fun refreshCategoryOrder() {
|
||||
val current = _categories.value
|
||||
if (current is UiState.Success) {
|
||||
_categories.value = UiState.Success(sortCategories(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshStreamOrder() {
|
||||
val current = _streams.value
|
||||
if (current is UiState.Success) {
|
||||
_streams.value = UiState.Success(sortStreams(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortCategories(categories: List<LiveCategoryDto>): List<LiveCategoryDto> =
|
||||
CategorySortUtils.sortLiveCategories(categories, usageRepo.liveCategoryCounts())
|
||||
|
||||
private fun sortStreams(streams: List<LiveStreamDto>): List<LiveStreamDto> =
|
||||
UsageSortUtils.sortByUsage(streams, usageRepo.liveStreamCounts()) { it.streamId.toString() }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.UserInfo
|
||||
|
||||
/**
|
||||
* ViewModel pour l'écran de connexion / configuration.
|
||||
* Gère la connexion Xtream et la configuration M3U.
|
||||
*/
|
||||
class LoginViewModel : ViewModel() {
|
||||
|
||||
private val authRepository: AuthRepository =
|
||||
XtreamApplication.instance.appContainer.authRepository
|
||||
private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository
|
||||
|
||||
private val _uiState = MutableStateFlow<UiState<UserInfo>>(UiState.Idle)
|
||||
val uiState: StateFlow<UiState<UserInfo>> = _uiState.asStateFlow()
|
||||
|
||||
/** Type de source (Compose-reactive). */
|
||||
var sourceType by mutableStateOf(SourceType.XTREAM)
|
||||
var serverUrl: String = ""
|
||||
private set
|
||||
var username: String = ""
|
||||
private set
|
||||
var password: String = ""
|
||||
private set
|
||||
var m3uUrl: String = ""
|
||||
private set
|
||||
|
||||
init {
|
||||
// Charger la config existante
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
if (config != null) {
|
||||
sourceType = config.sourceType
|
||||
serverUrl = config.serverUrl
|
||||
username = config.username
|
||||
password = config.password
|
||||
m3uUrl = config.m3uUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSourceType(type: SourceType) {
|
||||
sourceType = type
|
||||
}
|
||||
|
||||
fun updateServerUrl(value: String) {
|
||||
serverUrl = value.trim()
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
username = value.trim()
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
password = value.trim()
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updateM3uUrl(value: String) {
|
||||
m3uUrl = value.trim()
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
/** Connexion au serveur Xtream. */
|
||||
fun login() {
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
val result = authRepository.login(serverUrl, username, password)
|
||||
_uiState.value = result
|
||||
}
|
||||
}
|
||||
|
||||
/** Sauvegarde la config M3U et invalide le cache. */
|
||||
fun saveM3uConfig() {
|
||||
if (m3uUrl.isBlank()) return
|
||||
m3uRepository.invalidateCache()
|
||||
authRepository.saveM3uConfig(m3uUrl)
|
||||
_uiState.value = UiState.Success(UserInfo(username = "M3U"))
|
||||
}
|
||||
|
||||
/** Déconnexion : efface la config et le cache M3U. */
|
||||
fun logout() {
|
||||
m3uRepository.invalidateCache()
|
||||
authRepository.logout()
|
||||
serverUrl = ""
|
||||
username = ""
|
||||
password = ""
|
||||
m3uUrl = ""
|
||||
sourceType = SourceType.XTREAM
|
||||
_uiState.value = UiState.Idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.PlaybackProgressSnapshot
|
||||
import ma.tkgraph.xtreamtv.util.UrlBuilder
|
||||
|
||||
class MainMenuViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val container = XtreamApplication.instance.appContainer
|
||||
private val progressRepository = PlaybackProgressRepository(application)
|
||||
private val recentLiveRepository = container.recentLiveRepository
|
||||
|
||||
private val _continueItems = MutableStateFlow<UiState<List<ContinueWatchingItem>>>(UiState.Loading)
|
||||
val continueItems: StateFlow<UiState<List<ContinueWatchingItem>>> = _continueItems.asStateFlow()
|
||||
|
||||
private val _recentLiveItems = MutableStateFlow<UiState<List<RecentLiveItem>>>(UiState.Success(emptyList()))
|
||||
val recentLiveItems: StateFlow<UiState<List<RecentLiveItem>>> = _recentLiveItems.asStateFlow()
|
||||
|
||||
init {
|
||||
refreshRecentLive()
|
||||
refreshContinueWatching()
|
||||
}
|
||||
|
||||
|
||||
fun refreshRecentLive() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val items = recentLiveRepository.getRecentStreams()
|
||||
.map { entry ->
|
||||
RecentLiveItem(
|
||||
id = "live:${entry.stream.streamId}",
|
||||
title = entry.stream.name,
|
||||
subtitle = "LIVE • Dernière utilisation",
|
||||
imageUrl = entry.stream.streamIcon,
|
||||
lastUsedMs = entry.lastUsedMs,
|
||||
stream = entry.stream
|
||||
)
|
||||
}
|
||||
_recentLiveItems.value = UiState.Success(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordRecentLive(stream: LiveStreamDto) {
|
||||
recentLiveRepository.record(stream)
|
||||
refreshRecentLive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Version hybride V12 :
|
||||
* - affichage instantané avec les métadonnées locales quand elles existent ;
|
||||
* - hydratation legacy seulement pour les anciennes progressions sans métadonnées ;
|
||||
* - sauvegarde de ces métadonnées pour que les ouvertures suivantes soient rapides.
|
||||
*/
|
||||
fun refreshContinueWatching() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val allKeys = progressRepository.getInProgressKeys()
|
||||
if (allKeys.isEmpty()) {
|
||||
_continueItems.value = UiState.Success(emptyList())
|
||||
return@launch
|
||||
}
|
||||
|
||||
val metadataItems = allKeys
|
||||
.mapNotNull { key -> buildContinueItemFromMetadata(key) }
|
||||
.sortedByDescending { it.lastWatchedMs }
|
||||
|
||||
val missingKeys = allKeys
|
||||
.filter { key -> progressRepository.getContinueMetadata(key) == null }
|
||||
.toSet()
|
||||
|
||||
if (metadataItems.isNotEmpty()) {
|
||||
_continueItems.value = UiState.Success(metadataItems.take(MAX_CONTINUE_ITEMS))
|
||||
}
|
||||
|
||||
if (missingKeys.isEmpty()) {
|
||||
if (metadataItems.isEmpty()) _continueItems.value = UiState.Success(emptyList())
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Compatibilité avec les progressions créées avant la V11/V12.
|
||||
// Cette phase peut être plus lente, mais elle ne sert qu'à reconstruire les métadonnées manquantes.
|
||||
val legacyItems = mutableListOf<ContinueWatchingItem>()
|
||||
legacyItems += loadVodContinueItems(missingKeys)
|
||||
legacyItems += loadSeriesContinueItems(missingKeys)
|
||||
|
||||
legacyItems.forEach { item ->
|
||||
progressRepository.saveContinueMetadata(
|
||||
key = item.progressKey,
|
||||
stream = item.stream,
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
progressKeys = emptyList(),
|
||||
playbackUrl = item.stream.directUrl.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
|
||||
val merged = (metadataItems + legacyItems)
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.lastWatchedMs }
|
||||
.take(MAX_CONTINUE_ITEMS)
|
||||
|
||||
_continueItems.value = UiState.Success(merged)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildContinueItemFromMetadata(key: String): ContinueWatchingItem? {
|
||||
val metadata = progressRepository.getContinueMetadata(key) ?: return null
|
||||
val progress = progressRepository.getPlaybackProgress(key)
|
||||
if (!progress.hasProgress) return null
|
||||
val typeLabel = when {
|
||||
key.startsWith("vod:") -> "Film"
|
||||
key.startsWith("series:") -> "Série"
|
||||
else -> "Vidéo"
|
||||
}
|
||||
val resolvedAllStreams = metadata.allStreams.takeIf { it.isNotEmpty() } ?: emptyList()
|
||||
val resolvedIndex = metadata.currentIndex
|
||||
.takeIf { it >= 0 && it < resolvedAllStreams.size }
|
||||
?: 0
|
||||
val stream = resolvedAllStreams.getOrNull(resolvedIndex) ?: metadata.stream
|
||||
|
||||
return ContinueWatchingItem(
|
||||
id = key,
|
||||
title = metadata.title.ifBlank { stream.name },
|
||||
subtitle = "$typeLabel • ${progressText(progress)}",
|
||||
imageUrl = metadata.imageUrl ?: stream.streamIcon,
|
||||
progress = progress,
|
||||
lastWatchedMs = progressRepository.getLastWatched(key),
|
||||
stream = stream,
|
||||
allStreams = resolvedAllStreams,
|
||||
currentIndex = resolvedIndex,
|
||||
progressKey = key,
|
||||
progressKeys = metadata.progressKeys
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadVodContinueItems(allowedKeys: Set<String>): List<ContinueWatchingItem> {
|
||||
val vodKeys = allowedKeys.filter { it.startsWith("vod:") }
|
||||
if (vodKeys.isEmpty()) return emptyList()
|
||||
|
||||
val movies = when (val result = container.vodRepository.getMovies(null)) {
|
||||
is UiState.Success -> result.data.associateBy { it.streamId }
|
||||
else -> emptyMap()
|
||||
}
|
||||
val config = container.secureStorage.getCredentials() ?: return emptyList()
|
||||
val urlBuilder = UrlBuilder(config)
|
||||
|
||||
return vodKeys.mapNotNull { key ->
|
||||
val streamId = key.removePrefix("vod:").toIntOrNull() ?: return@mapNotNull null
|
||||
val movie = movies[streamId] ?: return@mapNotNull null
|
||||
val progress = progressRepository.getPlaybackProgress(key)
|
||||
if (!progress.hasProgress) return@mapNotNull null
|
||||
ContinueWatchingItem(
|
||||
id = key,
|
||||
title = movie.name,
|
||||
subtitle = "Film • ${progressText(progress)}",
|
||||
imageUrl = movie.streamIcon,
|
||||
progress = progress,
|
||||
lastWatchedMs = progressRepository.getLastWatched(key),
|
||||
stream = movie.toPlayerStream(urlBuilder),
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
progressKey = key,
|
||||
progressKeys = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSeriesContinueItems(allowedKeys: Set<String>): List<ContinueWatchingItem> {
|
||||
val seriesKeys = allowedKeys.filter { it.startsWith("series:") }
|
||||
if (seriesKeys.isEmpty()) return emptyList()
|
||||
|
||||
val keysBySeriesId = seriesKeys.mapNotNull { key ->
|
||||
val parts = key.split(":")
|
||||
val seriesId = parts.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null
|
||||
seriesId to key
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
|
||||
val seriesById = when (val result = container.seriesRepository.getSeriesList(null)) {
|
||||
is UiState.Success -> result.data.associateBy { it.seriesId }
|
||||
else -> emptyMap()
|
||||
}
|
||||
val config = container.secureStorage.getCredentials() ?: return emptyList()
|
||||
val urlBuilder = UrlBuilder(config)
|
||||
|
||||
return keysBySeriesId.mapNotNull { (seriesId, keys) ->
|
||||
val series = seriesById[seriesId] ?: return@mapNotNull null
|
||||
val info = when (val result = container.seriesRepository.getSeriesInfo(seriesId)) {
|
||||
is UiState.Success -> result.data
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val playableEpisodes = info.seasons.flatMap { season ->
|
||||
info.episodes[season.seasonNumber.toString()].orEmpty()
|
||||
}
|
||||
if (playableEpisodes.isEmpty()) return@mapNotNull null
|
||||
|
||||
val episodeStreams = playableEpisodes.map { episode -> episode.toPlayerStream(series, urlBuilder) }
|
||||
val progressKeys = playableEpisodes.map { episode -> "series:${series.seriesId}:${episode.id}" }
|
||||
val bestKey = keys.maxByOrNull { progressRepository.getLastWatched(it) } ?: return@mapNotNull null
|
||||
val index = progressKeys.indexOf(bestKey).takeIf { it >= 0 } ?: 0
|
||||
val episode = playableEpisodes.getOrNull(index) ?: return@mapNotNull null
|
||||
val progress = progressRepository.getPlaybackProgress(bestKey)
|
||||
if (!progress.hasProgress) return@mapNotNull null
|
||||
|
||||
ContinueWatchingItem(
|
||||
id = bestKey,
|
||||
title = series.name,
|
||||
subtitle = "Épisode ${episode.episodeNum} • ${episode.displayTitle().ifBlank { "Sans titre" }} • ${progressText(progress)}",
|
||||
imageUrl = episode.info?.movieImage ?: series.cover,
|
||||
progress = progress,
|
||||
lastWatchedMs = progressRepository.getLastWatched(bestKey),
|
||||
stream = episodeStreams[index],
|
||||
allStreams = episodeStreams,
|
||||
currentIndex = index,
|
||||
progressKey = bestKey,
|
||||
progressKeys = progressKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VodDto.toPlayerStream(urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto(
|
||||
name = name,
|
||||
streamId = streamId,
|
||||
streamIcon = streamIcon,
|
||||
containerExtension = containerExtension,
|
||||
directUrl = urlBuilder.vodUrl(streamId, containerExtension)
|
||||
)
|
||||
|
||||
private fun EpisodeDto.toPlayerStream(series: SeriesDto, urlBuilder: UrlBuilder): LiveStreamDto = LiveStreamDto(
|
||||
name = episodePlayerTitle(series.name, this),
|
||||
streamId = id,
|
||||
streamIcon = info?.movieImage ?: series.cover,
|
||||
containerExtension = containerExtension,
|
||||
directUrl = urlBuilder.seriesUrl(id, containerExtension)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONTINUE_ITEMS = 12
|
||||
}
|
||||
}
|
||||
|
||||
data class RecentLiveItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val imageUrl: String?,
|
||||
val lastUsedMs: Long,
|
||||
val stream: LiveStreamDto
|
||||
)
|
||||
|
||||
data class ContinueWatchingItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val imageUrl: String?,
|
||||
val progress: PlaybackProgressSnapshot,
|
||||
val lastWatchedMs: Long,
|
||||
val stream: LiveStreamDto,
|
||||
val allStreams: List<LiveStreamDto>,
|
||||
val currentIndex: Int,
|
||||
val progressKey: String,
|
||||
val progressKeys: List<String>
|
||||
)
|
||||
|
||||
private fun progressText(progress: PlaybackProgressSnapshot): String {
|
||||
return if (progress.percent > 0) "${progress.percent}%" else formatPlaybackTime(progress.positionMs)
|
||||
}
|
||||
|
||||
private fun EpisodeDto.displayTitle(): String = title ?: name ?: episodeName ?: ""
|
||||
|
||||
private fun episodePlayerTitle(seriesName: String, episode: EpisodeDto): String {
|
||||
val title = episode.displayTitle()
|
||||
return if (title.isBlank()) {
|
||||
"$seriesName - Ep ${episode.episodeNum}"
|
||||
} else {
|
||||
"$seriesName - Ep ${episode.episodeNum}: $title"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000L).coerceAtLeast(0L)
|
||||
val hours = totalSeconds / 3600L
|
||||
val minutes = (totalSeconds % 3600L) / 60L
|
||||
val seconds = totalSeconds % 60L
|
||||
return if (hours > 0L) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
|
||||
import ma.tkgraph.xtreamtv.util.UrlBuilder
|
||||
import timber.log.Timber
|
||||
|
||||
data class PlayerState(
|
||||
val stream: LiveStreamDto = LiveStreamDto(),
|
||||
val allStreams: List<LiveStreamDto> = emptyList(),
|
||||
val currentIndex: Int = 0,
|
||||
val showOverlay: Boolean = true,
|
||||
val resolution: String = "",
|
||||
val currentProgram: String = "",
|
||||
val nextProgram: String = "",
|
||||
val restartTrigger: Int = 0,
|
||||
val freezeRetries: Int = 0
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
|
||||
private val recentLiveRepo = XtreamApplication.instance.appContainer.recentLiveRepository
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState(showOverlay = true))
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
fun initialize(stream: LiveStreamDto, allStreams: List<LiveStreamDto>, index: Int) {
|
||||
_state.value = PlayerState(
|
||||
stream = stream,
|
||||
allStreams = allStreams,
|
||||
currentIndex = index,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(stream.streamId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL de lecture selon la source configurée.
|
||||
* - Mode Xtream : construit l'URL via [UrlBuilder]
|
||||
* - Mode M3U : utilise [LiveStreamDto.directUrl] directement
|
||||
*/
|
||||
fun buildUrl(stream: LiveStreamDto): String {
|
||||
// En mode M3U, utiliser l'URL directe du stream
|
||||
if (stream.directUrl.isNotBlank()) {
|
||||
return stream.directUrl
|
||||
}
|
||||
// Mode Xtream : construction via UrlBuilder
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
?: return ""
|
||||
if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) {
|
||||
return stream.directUrl
|
||||
}
|
||||
return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension)
|
||||
}
|
||||
|
||||
fun updateResolution(resolution: String) {
|
||||
_state.value = _state.value.copy(resolution = resolution)
|
||||
}
|
||||
|
||||
private fun loadEpg(streamId: Int) {
|
||||
viewModelScope.launch {
|
||||
val result = epgRepo.getShortEpg(streamId)
|
||||
if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) {
|
||||
val programs = result.data
|
||||
if (programs.isNotEmpty()) {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val current = programs.firstOrNull {
|
||||
val start = it.startTimestamp?.toLongOrNull()
|
||||
val stop = it.stopTimestamp?.toLongOrNull()
|
||||
start != null && start <= now && (stop == null || stop >= now)
|
||||
} ?: programs.firstOrNull()
|
||||
val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() }
|
||||
val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1]
|
||||
else null
|
||||
_state.value = _state.value.copy(
|
||||
currentProgram = current?.titleText() ?: "",
|
||||
nextProgram = next?.titleText() ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zappingLeft() {
|
||||
val current = _state.value
|
||||
if (current.allStreams.isEmpty()) return
|
||||
val newIndex = if (current.currentIndex > 0) current.currentIndex - 1
|
||||
else current.allStreams.lastIndex
|
||||
val newStream = current.allStreams[newIndex]
|
||||
recordLiveIfNeeded(newStream)
|
||||
_state.value = PlayerState(
|
||||
stream = newStream,
|
||||
allStreams = current.allStreams,
|
||||
currentIndex = newIndex,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(newStream.streamId)
|
||||
}
|
||||
|
||||
fun zappingRight() {
|
||||
val current = _state.value
|
||||
if (current.allStreams.isEmpty()) return
|
||||
val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1
|
||||
else 0
|
||||
val newStream = current.allStreams[newIndex]
|
||||
recordLiveIfNeeded(newStream)
|
||||
_state.value = PlayerState(
|
||||
stream = newStream,
|
||||
allStreams = current.allStreams,
|
||||
currentIndex = newIndex,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(newStream.streamId)
|
||||
}
|
||||
|
||||
|
||||
fun playNextItem(): Boolean {
|
||||
val current = _state.value
|
||||
if (current.allStreams.isEmpty() || current.currentIndex >= current.allStreams.lastIndex) return false
|
||||
val newIndex = current.currentIndex + 1
|
||||
val newStream = current.allStreams[newIndex]
|
||||
_state.value = current.copy(
|
||||
stream = newStream,
|
||||
currentIndex = newIndex,
|
||||
showOverlay = true,
|
||||
resolution = "",
|
||||
currentProgram = "",
|
||||
nextProgram = ""
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun toggleOverlay() {
|
||||
_state.value = _state.value.copy(showOverlay = !_state.value.showOverlay)
|
||||
}
|
||||
|
||||
fun hideOverlay() {
|
||||
_state.value = _state.value.copy(showOverlay = false)
|
||||
}
|
||||
|
||||
fun showOverlay() {
|
||||
_state.value = _state.value.copy(showOverlay = true)
|
||||
}
|
||||
|
||||
fun requestRestart() {
|
||||
val s = _state.value
|
||||
if (s.freezeRetries >= 3) return
|
||||
val retries = s.freezeRetries + 1
|
||||
Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3")
|
||||
_state.value = s.copy(
|
||||
restartTrigger = s.restartTrigger + 1,
|
||||
freezeRetries = retries
|
||||
)
|
||||
}
|
||||
|
||||
fun resetFreezeCounter() {
|
||||
val s = _state.value
|
||||
if (s.freezeRetries > 0) {
|
||||
_state.value = s.copy(freezeRetries = 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun canRestart(): Boolean = _state.value.freezeRetries < 3
|
||||
|
||||
private fun recordLiveIfNeeded(stream: LiveStreamDto) {
|
||||
if (!stream.streamType.equals("live", ignoreCase = true)) return
|
||||
usageRepo.recordLiveStream(stream.streamId)
|
||||
recentLiveRepo.record(stream)
|
||||
}
|
||||
|
||||
fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId)
|
||||
fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class ReplayViewModel : ViewModel() {
|
||||
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
|
||||
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
|
||||
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
|
||||
|
||||
private var currentStream: LiveStreamDto? = null
|
||||
|
||||
fun loadReplay(stream: LiveStreamDto) {
|
||||
currentStream = stream
|
||||
viewModelScope.launch {
|
||||
_epg.value = UiState.Loading
|
||||
val result = epgRepo.getFullGuide(stream.streamId)
|
||||
if (result is UiState.Success) {
|
||||
// Filtrer : garder uniquement les programmes passés (déjà diffusés)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val pastPrograms = result.data.filter { prog ->
|
||||
val start = prog.startTimestamp?.toLongOrNull() ?: 0
|
||||
start > 0 && start <= nowSeconds
|
||||
}.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 }
|
||||
_epg.value = UiState.Success(pastPrograms)
|
||||
} else {
|
||||
_epg.value = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildReplayUrl(program: EpgProgramDto): String? {
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
?: return null
|
||||
val stream = currentStream ?: return null
|
||||
|
||||
val durationMinutes = program.durationMinutes()
|
||||
|
||||
return ma.tkgraph.xtreamtv.util.UrlBuilder(config)
|
||||
.replayUrl(
|
||||
streamId = stream.streamId,
|
||||
start = program.start.orEmpty(),
|
||||
durationMinutes = durationMinutes,
|
||||
extension = "ts"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
|
||||
class SeriesViewModel : ViewModel() {
|
||||
|
||||
private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<SeriesCategoryDto>>>(UiState.Success(emptyList()))
|
||||
val categories: StateFlow<UiState<List<SeriesCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _series = MutableStateFlow<UiState<List<SeriesDto>>>(UiState.Success(emptyList()))
|
||||
val series: StateFlow<UiState<List<SeriesDto>>> = _series.asStateFlow()
|
||||
|
||||
private val _seriesInfo = MutableStateFlow<UiState<SeriesInfoDto>>(UiState.Idle)
|
||||
val seriesInfo: StateFlow<UiState<SeriesInfoDto>> = _seriesInfo.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadSeries()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getCategories()
|
||||
if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data))
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSeries(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getSeriesList(categoryId)
|
||||
if (r is UiState.Success) _series.value = UiState.Success(sortSeries(r.data))
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
usageRepo.recordSeriesCategory(categoryId)
|
||||
_selectedCategoryId.value = categoryId
|
||||
refreshCategoryOrder()
|
||||
if (categoryId == -1) {
|
||||
loadSeries(null) // Load all, filter in UI
|
||||
} else {
|
||||
loadSeries(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordSeriesUsage(seriesId: Int) {
|
||||
usageRepo.recordSeriesItem(seriesId)
|
||||
refreshSeriesOrder()
|
||||
}
|
||||
|
||||
fun loadSeriesInfo(seriesId: Int) {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getSeriesInfo(seriesId)
|
||||
if (r is UiState.Success) _seriesInfo.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(seriesId: Int) {
|
||||
favRepo.toggleFavoriteSeries(seriesId)
|
||||
_favoriteIds.value = favRepo.getFavoriteSeriesIds()
|
||||
}
|
||||
|
||||
private fun refreshCategoryOrder() {
|
||||
val current = _categories.value
|
||||
if (current is UiState.Success) {
|
||||
_categories.value = UiState.Success(sortCategories(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSeriesOrder() {
|
||||
val current = _series.value
|
||||
if (current is UiState.Success) {
|
||||
_series.value = UiState.Success(sortSeries(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortCategories(categories: List<SeriesCategoryDto>): List<SeriesCategoryDto> =
|
||||
CategorySortUtils.sortSeriesCategories(categories, usageRepo.seriesCategoryCounts())
|
||||
|
||||
private fun sortSeries(series: List<SeriesDto>): List<SeriesDto> =
|
||||
UsageSortUtils.sortByUsage(series, usageRepo.seriesItemCounts()) { it.seriesId.toString() }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
import ma.tkgraph.xtreamtv.util.CategorySortUtils
|
||||
import ma.tkgraph.xtreamtv.util.UsageSortUtils
|
||||
|
||||
class VodViewModel : ViewModel() {
|
||||
|
||||
private val vodRepo = XtreamApplication.instance.appContainer.vodRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
private val usageRepo = XtreamApplication.instance.appContainer.usageRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<VodCategoryDto>>>(UiState.Success(emptyList()))
|
||||
val categories: StateFlow<UiState<List<VodCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _movies = MutableStateFlow<UiState<List<VodDto>>>(UiState.Success(emptyList()))
|
||||
val movies: StateFlow<UiState<List<VodDto>>> = _movies.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadMovies()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val r = vodRepo.getCategories()
|
||||
if (r is UiState.Success) _categories.value = UiState.Success(sortCategories(r.data))
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMovies(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val r = vodRepo.getMovies(categoryId)
|
||||
if (r is UiState.Success) _movies.value = UiState.Success(sortMovies(r.data))
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
usageRepo.recordVodCategory(categoryId)
|
||||
_selectedCategoryId.value = categoryId
|
||||
refreshCategoryOrder()
|
||||
if (categoryId == -1) {
|
||||
loadMovies(null) // Load all, filter in UI
|
||||
} else {
|
||||
loadMovies(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordMovieUsage(streamId: Int) {
|
||||
usageRepo.recordVodItem(streamId)
|
||||
refreshMovieOrder()
|
||||
}
|
||||
|
||||
fun toggleFavorite(streamId: Int) {
|
||||
favRepo.toggleFavoriteVod(streamId)
|
||||
_favoriteIds.value = favRepo.getFavoriteVodIds()
|
||||
}
|
||||
|
||||
private fun refreshCategoryOrder() {
|
||||
val current = _categories.value
|
||||
if (current is UiState.Success) {
|
||||
_categories.value = UiState.Success(sortCategories(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMovieOrder() {
|
||||
val current = _movies.value
|
||||
if (current is UiState.Success) {
|
||||
_movies.value = UiState.Success(sortMovies(current.data))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortCategories(categories: List<VodCategoryDto>): List<VodCategoryDto> =
|
||||
CategorySortUtils.sortVodCategories(categories, usageRepo.vodCategoryCounts())
|
||||
|
||||
private fun sortMovies(movies: List<VodDto>): List<VodDto> =
|
||||
UsageSortUtils.sortByUsage(movies, usageRepo.vodItemCounts()) { it.streamId.toString() }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ma.tkgraph.xtreamtv.data.repository.YoutubeRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.YoutubeVideoItem
|
||||
|
||||
class YoutubeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = YoutubeRepository(application)
|
||||
|
||||
private val _videos = MutableStateFlow(repository.getVideos())
|
||||
val videos: StateFlow<List<YoutubeVideoItem>> = _videos.asStateFlow()
|
||||
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
val message: StateFlow<String?> = _message.asStateFlow()
|
||||
|
||||
fun addVideo(title: String, input: String): Boolean {
|
||||
val result = repository.addVideo(title, input)
|
||||
return result.fold(
|
||||
onSuccess = {
|
||||
refresh()
|
||||
_message.value = "Vidéo ajoutée"
|
||||
true
|
||||
},
|
||||
onFailure = {
|
||||
_message.value = it.message ?: "Impossible d'ajouter cette vidéo"
|
||||
false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteVideo(id: String) {
|
||||
repository.deleteVideo(id)
|
||||
refresh()
|
||||
_message.value = "Vidéo supprimée"
|
||||
}
|
||||
|
||||
fun markPlayed(id: String) {
|
||||
repository.markPlayed(id)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_message.value = null
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
_videos.value = repository.getVideos()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
|
||||
/**
|
||||
* Trie les catégories :
|
||||
* 1. catégories spéciales éventuelles
|
||||
* 2. catégories les plus utilisées
|
||||
* 3. priorités historiques du projet
|
||||
* 4. ordre alphabétique
|
||||
*/
|
||||
object CategorySortUtils {
|
||||
|
||||
private val PRIORITY_KEYWORDS = listOf("bein", "beIN")
|
||||
|
||||
private val FRENCH_KEYWORDS = listOf(
|
||||
"france", "français", "francaise", "française",
|
||||
"fr", "fra", "francais", "info", "infos"
|
||||
)
|
||||
|
||||
private val ARABIC_KEYWORDS = listOf(
|
||||
"arab", "arabic", "arabe", "arabia",
|
||||
"arabique", "maghreb", "moyen-orient",
|
||||
"عربي", "عربية", "العربية"
|
||||
)
|
||||
|
||||
private val SPECIAL_NAMES = listOf(
|
||||
"all channels", "toutes les chaînes",
|
||||
"all movies", "tous les films",
|
||||
"all series", "toutes les séries",
|
||||
"favorites", "favoris", "★"
|
||||
)
|
||||
|
||||
fun sortLiveCategories(
|
||||
categories: List<LiveCategoryDto>,
|
||||
usageCounts: Map<String, Int> = emptyMap()
|
||||
): List<LiveCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
idSelector = { it.categoryId },
|
||||
usageCounts = usageCounts,
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
fun sortVodCategories(
|
||||
categories: List<VodCategoryDto>,
|
||||
usageCounts: Map<String, Int> = emptyMap()
|
||||
): List<VodCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
idSelector = { it.categoryId },
|
||||
usageCounts = usageCounts,
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
fun sortSeriesCategories(
|
||||
categories: List<SeriesCategoryDto>,
|
||||
usageCounts: Map<String, Int> = emptyMap()
|
||||
): List<SeriesCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
idSelector = { it.categoryId },
|
||||
usageCounts = usageCounts,
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
private fun <T> categoryComparator(
|
||||
nameSelector: (T) -> String,
|
||||
idSelector: (T) -> String,
|
||||
usageCounts: Map<String, Int>,
|
||||
isSpecial: (String) -> Boolean
|
||||
): Comparator<T> = Comparator { a, b ->
|
||||
val nameA = nameSelector(a).lowercase().trim()
|
||||
val nameB = nameSelector(b).lowercase().trim()
|
||||
val usageA = usageCounts[idSelector(a)] ?: 0
|
||||
val usageB = usageCounts[idSelector(b)] ?: 0
|
||||
|
||||
when {
|
||||
isSpecial(nameA) && !isSpecial(nameB) -> -1
|
||||
!isSpecial(nameA) && isSpecial(nameB) -> 1
|
||||
usageA != usageB -> usageB.compareTo(usageA)
|
||||
isPriority(nameA) && !isPriority(nameB) -> -1
|
||||
!isPriority(nameA) && isPriority(nameB) -> 1
|
||||
isFrench(nameA) && !isFrench(nameB) -> -1
|
||||
!isFrench(nameA) && isFrench(nameB) -> 1
|
||||
isArabic(nameA) && !isArabic(nameB) -> -1
|
||||
!isArabic(nameA) && isArabic(nameB) -> 1
|
||||
else -> nameA.compareTo(nameB)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPriority(name: String): Boolean = PRIORITY_KEYWORDS.any { name.contains(it) }
|
||||
private fun isFrench(name: String): Boolean = FRENCH_KEYWORDS.any { name.contains(it) }
|
||||
private fun isArabic(name: String): Boolean =
|
||||
ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
|
||||
/**
|
||||
* Vérifie l'état de la session au démarrage.
|
||||
*/
|
||||
class SessionManager(private val storage: SecureStorage) {
|
||||
|
||||
fun hasValidSession(): Boolean {
|
||||
val config = storage.getConfig() ?: return false
|
||||
return config.isValid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
|
||||
class UrlBuilder(private val config: XtreamConfig) {
|
||||
|
||||
fun liveUrl(streamId: Int, extension: String = "m3u8"): String =
|
||||
"${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension"
|
||||
|
||||
fun vodUrl(streamId: Int, extension: String = "mp4"): String =
|
||||
"${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension"
|
||||
|
||||
fun seriesUrl(episodeId: Int, extension: String = "mp4"): String =
|
||||
"${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension"
|
||||
|
||||
/**
|
||||
* Construit l'URL timeshift pour le replay.
|
||||
* Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts
|
||||
*
|
||||
* @param streamId ID de la chaîne
|
||||
* @param start Date de début au format "2026-06-11 20:00"
|
||||
* @param durationMinutes Durée en minutes (entre 1 et 720)
|
||||
* @param extension Extension du fichier (ts par défaut)
|
||||
*/
|
||||
fun replayUrl(
|
||||
streamId: Int,
|
||||
start: String,
|
||||
durationMinutes: Int = 120,
|
||||
extension: String = "ts"
|
||||
): String {
|
||||
val safeDuration = durationMinutes.coerceIn(1, 720)
|
||||
val formattedStart = formatTimeshiftStart(start)
|
||||
return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la date pour le timeshift.
|
||||
* "2026-06-11 20:00" → "2026-06-11:20-00"
|
||||
*/
|
||||
private fun formatTimeshiftStart(start: String): String {
|
||||
val match = Regex("""(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})""").find(start.trim())
|
||||
if (match != null) {
|
||||
val date = match.groupValues[1]
|
||||
val hour = match.groupValues[2]
|
||||
val minute = match.groupValues[3]
|
||||
return "$date:$hour-$minute"
|
||||
}
|
||||
return start.trim().take(16).replace(" ", ":").replace(Regex(""":(\d{2})$"""), "-$1")
|
||||
}
|
||||
|
||||
private val baseUrl: String
|
||||
get() = config.serverUrl.trimEnd('/')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
/**
|
||||
* Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur.
|
||||
* L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement.
|
||||
*/
|
||||
object UsageSortUtils {
|
||||
fun <T> sortByUsage(
|
||||
items: List<T>,
|
||||
usageCounts: Map<String, Int>,
|
||||
idSelector: (T) -> String
|
||||
): List<T> {
|
||||
if (items.size < 2 || usageCounts.isEmpty()) return items
|
||||
return items.withIndex()
|
||||
.sortedWith(
|
||||
compareByDescending<IndexedValue<T>> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 }
|
||||
.thenBy { indexed -> indexed.index }
|
||||
)
|
||||
.map { it.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Petites fonctions communes pour transformer un code/lien YouTube en cible lisible dans WebView.
|
||||
* On accepte : code vidéo, youtube.com/watch?v=..., youtu.be/..., shorts, live et playlist.
|
||||
*/
|
||||
object YoutubeUrlUtils {
|
||||
const val DEFAULT_WATCH_PREFIX = "https://www.youtube.com/watch?v="
|
||||
const val WEBVIEW_ORIGIN = "https://imedia-youtube.local/"
|
||||
const val DESKTOP_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
private val youtubeVideoIdPattern = Regex("^[A-Za-z0-9_-]{8,20}$")
|
||||
|
||||
fun normalize(raw: String): String {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank() || value == DEFAULT_WATCH_PREFIX) return ""
|
||||
|
||||
return when {
|
||||
value.startsWith("http://") || value.startsWith("https://") -> value
|
||||
value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value"
|
||||
value.startsWith("watch?v=") -> "https://www.youtube.com/$value"
|
||||
value.startsWith("v=") -> DEFAULT_WATCH_PREFIX + value.removePrefix("v=")
|
||||
youtubeVideoIdPattern.matches(value) -> DEFAULT_WATCH_PREFIX + value
|
||||
else -> "https://$value"
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(raw: String): YoutubeTarget? {
|
||||
val normalized = normalize(raw)
|
||||
if (normalized.isBlank()) return null
|
||||
|
||||
return runCatching {
|
||||
val uri = Uri.parse(normalized)
|
||||
val host = uri.host.orEmpty().lowercase()
|
||||
val pathSegments = uri.pathSegments
|
||||
|
||||
val videoId = when {
|
||||
host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull()
|
||||
uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v")
|
||||
pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1]
|
||||
else -> null
|
||||
}?.takeIf { it.length in 8..20 }
|
||||
|
||||
val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() }
|
||||
|
||||
YoutubeTarget(
|
||||
normalizedUrl = normalized,
|
||||
videoId = videoId,
|
||||
playlistId = playlistId,
|
||||
siteUrl = normalized
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun youtubeHeaders(): Map<String, String> = mapOf(
|
||||
"Referer" to WEBVIEW_ORIGIN,
|
||||
"Origin" to WEBVIEW_ORIGIN.trimEnd('/')
|
||||
)
|
||||
|
||||
fun buildIframeHtml(target: YoutubeTarget): String {
|
||||
val embedUrl = target.toEmbedUrl().escapeHtml()
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<style>
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#ytplayer { width:100%; height:100%; background:#000; border:0; display:block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="ytplayer"
|
||||
src="$embedUrl"
|
||||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin">
|
||||
</iframe>
|
||||
<script src="https://www.youtube.com/iframe_api"></script>
|
||||
<script>
|
||||
var player = null;
|
||||
|
||||
function logImedia(message) {
|
||||
try { console.log('IMEDIA_YT:' + message); } catch(e) {}
|
||||
}
|
||||
|
||||
function onYouTubeIframeAPIReady() {
|
||||
player = new YT.Player('ytplayer', {
|
||||
events: {
|
||||
'onReady': function(event) {
|
||||
logImedia('Player prêt');
|
||||
try { event.target.playVideo(); } catch(e) { logImedia('Cliquez Play si nécessaire'); }
|
||||
},
|
||||
'onStateChange': function(event) {
|
||||
if (event.data === YT.PlayerState.PLAYING) logImedia('Lecture YouTube');
|
||||
if (event.data === YT.PlayerState.PAUSED) logImedia('Pause YouTube');
|
||||
if (event.data === YT.PlayerState.BUFFERING) logImedia('Chargement YouTube');
|
||||
if (event.data === YT.PlayerState.ENDED) logImedia('Vidéo terminée');
|
||||
},
|
||||
'onError': function(event) {
|
||||
logImedia('YouTube erreur ' + event.data + ' : embed refusé');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!player || !player.getPlayerState) return;
|
||||
var state = player.getPlayerState();
|
||||
if (state === YT.PlayerState.PLAYING) {
|
||||
player.pauseVideo();
|
||||
} else {
|
||||
player.playVideo();
|
||||
}
|
||||
}
|
||||
|
||||
function seekBy(seconds) {
|
||||
if (!player || !player.getCurrentTime || !player.seekTo) return;
|
||||
var current = 0;
|
||||
try { current = player.getCurrentTime(); } catch(e) { current = 0; }
|
||||
player.seekTo(Math.max(0, current + seconds), true);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun String.escapeHtml(): String =
|
||||
replace("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
data class YoutubeTarget(
|
||||
val normalizedUrl: String,
|
||||
val videoId: String?,
|
||||
val playlistId: String?,
|
||||
val siteUrl: String?
|
||||
) {
|
||||
val canUseEmbed: Boolean = videoId != null || playlistId != null
|
||||
|
||||
fun toEmbedUrl(): String {
|
||||
val origin = Uri.encode(YoutubeUrlUtils.WEBVIEW_ORIGIN.trimEnd('/'))
|
||||
val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin"
|
||||
return when {
|
||||
videoId != null -> "https://www.youtube.com/embed/$videoId?$common"
|
||||
playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${Uri.encode(playlistId)}&$common"
|
||||
else -> normalizedUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun toWatchOrNormalizedUrl(): String = when {
|
||||
videoId != null -> "https://www.youtube.com/watch?v=$videoId"
|
||||
else -> siteUrl ?: normalizedUrl
|
||||
}
|
||||
|
||||
fun thumbnailUrl(): String? = videoId?.let { "https://img.youtube.com/vi/$it/hqdefault.jpg" }
|
||||
}
|
||||
Reference in New Issue
Block a user