mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
feat: implement subtitle management with addon support
This commit is contained in:
parent
eccc730efa
commit
aed09c07a8
10 changed files with 780 additions and 32 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,4 +13,5 @@
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
libass-android
|
libass-android
|
||||||
|
Stremio addons refer
|
||||||
|
|
@ -4,11 +4,13 @@ import com.nuvio.tv.data.repository.AddonRepositoryImpl
|
||||||
import com.nuvio.tv.data.repository.CatalogRepositoryImpl
|
import com.nuvio.tv.data.repository.CatalogRepositoryImpl
|
||||||
import com.nuvio.tv.data.repository.MetaRepositoryImpl
|
import com.nuvio.tv.data.repository.MetaRepositoryImpl
|
||||||
import com.nuvio.tv.data.repository.StreamRepositoryImpl
|
import com.nuvio.tv.data.repository.StreamRepositoryImpl
|
||||||
|
import com.nuvio.tv.data.repository.SubtitleRepositoryImpl
|
||||||
import com.nuvio.tv.data.repository.WatchProgressRepositoryImpl
|
import com.nuvio.tv.data.repository.WatchProgressRepositoryImpl
|
||||||
import com.nuvio.tv.domain.repository.AddonRepository
|
import com.nuvio.tv.domain.repository.AddonRepository
|
||||||
import com.nuvio.tv.domain.repository.CatalogRepository
|
import com.nuvio.tv.domain.repository.CatalogRepository
|
||||||
import com.nuvio.tv.domain.repository.MetaRepository
|
import com.nuvio.tv.domain.repository.MetaRepository
|
||||||
import com.nuvio.tv.domain.repository.StreamRepository
|
import com.nuvio.tv.domain.repository.StreamRepository
|
||||||
|
import com.nuvio.tv.domain.repository.SubtitleRepository
|
||||||
import com.nuvio.tv.domain.repository.WatchProgressRepository
|
import com.nuvio.tv.domain.repository.WatchProgressRepository
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -36,6 +38,10 @@ abstract class RepositoryModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
|
abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSubtitleRepository(impl: SubtitleRepositoryImpl): SubtitleRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindWatchProgressRepository(impl: WatchProgressRepositoryImpl): WatchProgressRepository
|
abstract fun bindWatchProgressRepository(impl: WatchProgressRepositoryImpl): WatchProgressRepository
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.nuvio.tv.data.remote.dto.AddonManifestDto
|
||||||
import com.nuvio.tv.data.remote.dto.CatalogResponseDto
|
import com.nuvio.tv.data.remote.dto.CatalogResponseDto
|
||||||
import com.nuvio.tv.data.remote.dto.MetaResponseDto
|
import com.nuvio.tv.data.remote.dto.MetaResponseDto
|
||||||
import com.nuvio.tv.data.remote.dto.StreamResponseDto
|
import com.nuvio.tv.data.remote.dto.StreamResponseDto
|
||||||
|
import com.nuvio.tv.data.remote.dto.SubtitleResponseDto
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
|
|
@ -21,4 +22,7 @@ interface AddonApi {
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
suspend fun getStreams(@Url streamUrl: String): Response<StreamResponseDto>
|
suspend fun getStreams(@Url streamUrl: String): Response<StreamResponseDto>
|
||||||
|
|
||||||
|
@GET
|
||||||
|
suspend fun getSubtitles(@Url subtitleUrl: String): Response<SubtitleResponseDto>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.nuvio.tv.data.remote.dto
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class SubtitleResponseDto(
|
||||||
|
@Json(name = "subtitles") val subtitles: List<SubtitleItemDto>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class SubtitleItemDto(
|
||||||
|
@Json(name = "id") val id: String? = null,
|
||||||
|
@Json(name = "url") val url: String,
|
||||||
|
@Json(name = "lang") val lang: String
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package com.nuvio.tv.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.nuvio.tv.core.network.NetworkResult
|
||||||
|
import com.nuvio.tv.core.network.safeApiCall
|
||||||
|
import com.nuvio.tv.data.local.AddonPreferences
|
||||||
|
import com.nuvio.tv.data.remote.api.AddonApi
|
||||||
|
import com.nuvio.tv.domain.model.Addon
|
||||||
|
import com.nuvio.tv.domain.model.Subtitle
|
||||||
|
import com.nuvio.tv.domain.repository.SubtitleRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SubtitleRepositoryImpl @Inject constructor(
|
||||||
|
private val api: AddonApi,
|
||||||
|
private val addonRepository: AddonRepositoryImpl
|
||||||
|
) : SubtitleRepository {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SubtitleRepository"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSubtitles(
|
||||||
|
type: String,
|
||||||
|
id: String,
|
||||||
|
videoId: String?,
|
||||||
|
videoHash: String?,
|
||||||
|
videoSize: Long?,
|
||||||
|
filename: String?
|
||||||
|
): List<Subtitle> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "Fetching subtitles for type=$type, id=$id, videoId=$videoId")
|
||||||
|
|
||||||
|
// Get installed addons
|
||||||
|
val addons = try {
|
||||||
|
addonRepository.getInstalledAddons().first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get installed addons", e)
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter addons that support subtitles resource
|
||||||
|
val subtitleAddons = addons.filter { addon ->
|
||||||
|
addon.resources.any { resource ->
|
||||||
|
resource.name == "subtitles" && supportsType(resource, type, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Found ${subtitleAddons.size} subtitle addons: ${subtitleAddons.map { it.name }}")
|
||||||
|
|
||||||
|
if (subtitleAddons.isEmpty()) {
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subtitles from all addons in parallel
|
||||||
|
coroutineScope {
|
||||||
|
subtitleAddons.map { addon ->
|
||||||
|
async {
|
||||||
|
fetchSubtitlesFromAddon(addon, type, id, videoId, videoHash, videoSize, filename)
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun supportsType(resource: com.nuvio.tv.domain.model.AddonResource, type: String, id: String): Boolean {
|
||||||
|
// Check if type is supported
|
||||||
|
if (resource.types.isNotEmpty() && !resource.types.contains(type)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if id prefix is supported
|
||||||
|
val idPrefixes = resource.idPrefixes
|
||||||
|
if (idPrefixes != null && idPrefixes.isNotEmpty()) {
|
||||||
|
return idPrefixes.any { prefix -> id.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchSubtitlesFromAddon(
|
||||||
|
addon: Addon,
|
||||||
|
type: String,
|
||||||
|
id: String,
|
||||||
|
videoId: String?,
|
||||||
|
videoHash: String?,
|
||||||
|
videoSize: Long?,
|
||||||
|
filename: String?
|
||||||
|
): List<Subtitle> {
|
||||||
|
val actualId = if (type == "series" && videoId != null) {
|
||||||
|
// For series, use videoId which includes season/episode
|
||||||
|
videoId
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the subtitle URL with optional extra parameters
|
||||||
|
val baseUrl = addon.baseUrl.trimEnd('/')
|
||||||
|
val extraParams = buildExtraParams(videoHash, videoSize, filename)
|
||||||
|
val subtitleUrl = if (extraParams.isNotEmpty()) {
|
||||||
|
"$baseUrl/subtitles/$type/$actualId/$extraParams.json"
|
||||||
|
} else {
|
||||||
|
"$baseUrl/subtitles/$type/$actualId.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Fetching subtitles from ${addon.name}: $subtitleUrl")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when (val result = safeApiCall { api.getSubtitles(subtitleUrl) }) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val subtitles = result.data.subtitles?.mapNotNull { dto ->
|
||||||
|
Subtitle(
|
||||||
|
id = dto.id ?: "${dto.lang}-${dto.url.hashCode()}",
|
||||||
|
url = dto.url,
|
||||||
|
lang = dto.lang,
|
||||||
|
addonName = addon.name,
|
||||||
|
addonLogo = addon.logo
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
Log.d(TAG, "Got ${subtitles.size} subtitles from ${addon.name}")
|
||||||
|
subtitles
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to fetch subtitles from ${addon.name}: ${result.message}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
NetworkResult.Loading -> emptyList()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception fetching subtitles from ${addon.name}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildExtraParams(
|
||||||
|
videoHash: String?,
|
||||||
|
videoSize: Long?,
|
||||||
|
filename: String?
|
||||||
|
): String {
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
|
||||||
|
videoHash?.let { params.add("videoHash=$it") }
|
||||||
|
videoSize?.let { params.add("videoSize=$it") }
|
||||||
|
filename?.let { params.add("filename=$it") }
|
||||||
|
|
||||||
|
return if (params.isNotEmpty()) {
|
||||||
|
params.joinToString("&")
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/src/main/java/com/nuvio/tv/domain/model/Subtitle.kt
Normal file
107
app/src/main/java/com/nuvio/tv/domain/model/Subtitle.kt
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package com.nuvio.tv.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a subtitle from a Stremio addon
|
||||||
|
*/
|
||||||
|
data class Subtitle(
|
||||||
|
val id: String,
|
||||||
|
val url: String,
|
||||||
|
val lang: String,
|
||||||
|
val addonName: String,
|
||||||
|
val addonLogo: String?
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Returns a human-readable language name
|
||||||
|
*/
|
||||||
|
fun getDisplayLanguage(): String {
|
||||||
|
return languageCodeToName(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val languageNames = mapOf(
|
||||||
|
"en" to "English",
|
||||||
|
"eng" to "English",
|
||||||
|
"es" to "Spanish",
|
||||||
|
"spa" to "Spanish",
|
||||||
|
"fr" to "French",
|
||||||
|
"fra" to "French",
|
||||||
|
"fre" to "French",
|
||||||
|
"de" to "German",
|
||||||
|
"deu" to "German",
|
||||||
|
"ger" to "German",
|
||||||
|
"it" to "Italian",
|
||||||
|
"ita" to "Italian",
|
||||||
|
"pt" to "Portuguese",
|
||||||
|
"por" to "Portuguese",
|
||||||
|
"pt-br" to "Portuguese (Brazil)",
|
||||||
|
"ru" to "Russian",
|
||||||
|
"rus" to "Russian",
|
||||||
|
"ja" to "Japanese",
|
||||||
|
"jpn" to "Japanese",
|
||||||
|
"ko" to "Korean",
|
||||||
|
"kor" to "Korean",
|
||||||
|
"zh" to "Chinese",
|
||||||
|
"chi" to "Chinese",
|
||||||
|
"zho" to "Chinese",
|
||||||
|
"ar" to "Arabic",
|
||||||
|
"ara" to "Arabic",
|
||||||
|
"hi" to "Hindi",
|
||||||
|
"hin" to "Hindi",
|
||||||
|
"nl" to "Dutch",
|
||||||
|
"nld" to "Dutch",
|
||||||
|
"dut" to "Dutch",
|
||||||
|
"pl" to "Polish",
|
||||||
|
"pol" to "Polish",
|
||||||
|
"sv" to "Swedish",
|
||||||
|
"swe" to "Swedish",
|
||||||
|
"no" to "Norwegian",
|
||||||
|
"nor" to "Norwegian",
|
||||||
|
"da" to "Danish",
|
||||||
|
"dan" to "Danish",
|
||||||
|
"fi" to "Finnish",
|
||||||
|
"fin" to "Finnish",
|
||||||
|
"tr" to "Turkish",
|
||||||
|
"tur" to "Turkish",
|
||||||
|
"el" to "Greek",
|
||||||
|
"ell" to "Greek",
|
||||||
|
"gre" to "Greek",
|
||||||
|
"he" to "Hebrew",
|
||||||
|
"heb" to "Hebrew",
|
||||||
|
"th" to "Thai",
|
||||||
|
"tha" to "Thai",
|
||||||
|
"vi" to "Vietnamese",
|
||||||
|
"vie" to "Vietnamese",
|
||||||
|
"id" to "Indonesian",
|
||||||
|
"ind" to "Indonesian",
|
||||||
|
"ms" to "Malay",
|
||||||
|
"msa" to "Malay",
|
||||||
|
"may" to "Malay",
|
||||||
|
"cs" to "Czech",
|
||||||
|
"ces" to "Czech",
|
||||||
|
"cze" to "Czech",
|
||||||
|
"hu" to "Hungarian",
|
||||||
|
"hun" to "Hungarian",
|
||||||
|
"ro" to "Romanian",
|
||||||
|
"ron" to "Romanian",
|
||||||
|
"rum" to "Romanian",
|
||||||
|
"uk" to "Ukrainian",
|
||||||
|
"ukr" to "Ukrainian",
|
||||||
|
"bg" to "Bulgarian",
|
||||||
|
"bul" to "Bulgarian",
|
||||||
|
"hr" to "Croatian",
|
||||||
|
"hrv" to "Croatian",
|
||||||
|
"sr" to "Serbian",
|
||||||
|
"srp" to "Serbian",
|
||||||
|
"sk" to "Slovak",
|
||||||
|
"slk" to "Slovak",
|
||||||
|
"slo" to "Slovak",
|
||||||
|
"sl" to "Slovenian",
|
||||||
|
"slv" to "Slovenian"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun languageCodeToName(code: String): String {
|
||||||
|
val lowerCode = code.lowercase()
|
||||||
|
return languageNames[lowerCode] ?: code.uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.nuvio.tv.domain.repository
|
||||||
|
|
||||||
|
import com.nuvio.tv.domain.model.Subtitle
|
||||||
|
|
||||||
|
interface SubtitleRepository {
|
||||||
|
/**
|
||||||
|
* Fetches subtitles from all installed addons that support subtitles
|
||||||
|
* @param type Content type (movie, series, etc.)
|
||||||
|
* @param id Content ID (IMDB ID, etc.)
|
||||||
|
* @param videoId Optional video ID for series (e.g., tt1234567:1:1 for series episode)
|
||||||
|
* @param videoHash Optional OpenSubtitles file hash
|
||||||
|
* @param videoSize Optional video file size in bytes
|
||||||
|
* @param filename Optional video filename
|
||||||
|
* @return List of subtitles from all addons
|
||||||
|
*/
|
||||||
|
suspend fun getSubtitles(
|
||||||
|
type: String,
|
||||||
|
id: String,
|
||||||
|
videoId: String? = null,
|
||||||
|
videoHash: String? = null,
|
||||||
|
videoSize: Long? = null,
|
||||||
|
filename: String? = null
|
||||||
|
): List<Subtitle>
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -77,6 +78,7 @@ import androidx.tv.material3.IconButton
|
||||||
import androidx.tv.material3.IconButtonDefaults
|
import androidx.tv.material3.IconButtonDefaults
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import com.nuvio.tv.domain.model.Subtitle
|
||||||
import com.nuvio.tv.ui.components.LoadingIndicator
|
import com.nuvio.tv.ui.components.LoadingIndicator
|
||||||
import com.nuvio.tv.ui.theme.NuvioColors
|
import com.nuvio.tv.ui.theme.NuvioColors
|
||||||
import com.nuvio.tv.ui.theme.NuvioTheme
|
import com.nuvio.tv.ui.theme.NuvioTheme
|
||||||
|
|
@ -450,9 +452,13 @@ fun PlayerScreen(
|
||||||
// Subtitle track dialog
|
// Subtitle track dialog
|
||||||
if (uiState.showSubtitleDialog) {
|
if (uiState.showSubtitleDialog) {
|
||||||
SubtitleSelectionDialog(
|
SubtitleSelectionDialog(
|
||||||
tracks = uiState.subtitleTracks,
|
internalTracks = uiState.subtitleTracks,
|
||||||
selectedIndex = uiState.selectedSubtitleTrackIndex,
|
selectedInternalIndex = uiState.selectedSubtitleTrackIndex,
|
||||||
onTrackSelected = { viewModel.onEvent(PlayerEvent.OnSelectSubtitleTrack(it)) },
|
addonSubtitles = uiState.addonSubtitles,
|
||||||
|
selectedAddonSubtitle = uiState.selectedAddonSubtitle,
|
||||||
|
isLoadingAddons = uiState.isLoadingAddonSubtitles,
|
||||||
|
onInternalTrackSelected = { viewModel.onEvent(PlayerEvent.OnSelectSubtitleTrack(it)) },
|
||||||
|
onAddonSubtitleSelected = { viewModel.onEvent(PlayerEvent.OnSelectAddonSubtitle(it)) },
|
||||||
onDisableSubtitles = { viewModel.onEvent(PlayerEvent.OnDisableSubtitles) },
|
onDisableSubtitles = { viewModel.onEvent(PlayerEvent.OnDisableSubtitles) },
|
||||||
onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
|
onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
|
||||||
)
|
)
|
||||||
|
|
@ -856,16 +862,24 @@ private fun TrackSelectionDialog(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubtitleSelectionDialog(
|
private fun SubtitleSelectionDialog(
|
||||||
tracks: List<TrackInfo>,
|
internalTracks: List<TrackInfo>,
|
||||||
selectedIndex: Int,
|
selectedInternalIndex: Int,
|
||||||
onTrackSelected: (Int) -> Unit,
|
addonSubtitles: List<Subtitle>,
|
||||||
|
selectedAddonSubtitle: Subtitle?,
|
||||||
|
isLoadingAddons: Boolean,
|
||||||
|
onInternalTrackSelected: (Int) -> Unit,
|
||||||
|
onAddonSubtitleSelected: (Subtitle) -> Unit,
|
||||||
onDisableSubtitles: () -> Unit,
|
onDisableSubtitles: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||||
|
val tabs = listOf("Internal", "Addons")
|
||||||
|
val tabFocusRequesters = remember { tabs.map { FocusRequester() } }
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(400.dp)
|
.width(450.dp)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(NuvioColors.BackgroundElevated)
|
.background(NuvioColors.BackgroundElevated)
|
||||||
) {
|
) {
|
||||||
|
|
@ -878,32 +892,282 @@ private fun SubtitleSelectionDialog(
|
||||||
color = NuvioColors.TextPrimary,
|
color = NuvioColors.TextPrimary,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
TvLazyColumn(
|
// Tab row
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
Row(
|
||||||
contentPadding = PaddingValues(top = 4.dp),
|
horizontalArrangement = Arrangement.Center,
|
||||||
modifier = Modifier.height(300.dp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
) {
|
) {
|
||||||
// Off option
|
tabs.forEachIndexed { index, _ ->
|
||||||
item {
|
SubtitleTab(
|
||||||
TrackItem(
|
title = tabs[index],
|
||||||
track = TrackInfo(index = -1, name = "Off", language = null),
|
isSelected = selectedTabIndex == index,
|
||||||
isSelected = selectedIndex == -1,
|
badgeCount = if (index == 1) addonSubtitles.size else null,
|
||||||
onClick = onDisableSubtitles
|
focusRequester = tabFocusRequesters[index],
|
||||||
|
onClick = { selectedTabIndex = index }
|
||||||
|
)
|
||||||
|
if (index < tabs.lastIndex) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content based on selected tab
|
||||||
|
when (selectedTabIndex) {
|
||||||
|
0 -> {
|
||||||
|
// Internal subtitles tab
|
||||||
|
InternalSubtitlesContent(
|
||||||
|
tracks = internalTracks,
|
||||||
|
selectedIndex = selectedInternalIndex,
|
||||||
|
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||||
|
onTrackSelected = onInternalTrackSelected,
|
||||||
|
onDisableSubtitles = onDisableSubtitles
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
1 -> {
|
||||||
items(tracks) { track ->
|
// Addon subtitles tab
|
||||||
TrackItem(
|
AddonSubtitlesContent(
|
||||||
track = track,
|
subtitles = addonSubtitles,
|
||||||
isSelected = track.index == selectedIndex,
|
selectedSubtitle = selectedAddonSubtitle,
|
||||||
onClick = { onTrackSelected(track.index) }
|
isLoading = isLoadingAddons,
|
||||||
|
onSubtitleSelected = onAddonSubtitleSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request focus on the first tab when dialog opens
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
try {
|
||||||
|
tabFocusRequesters[0].requestFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Focus requester may not be ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubtitleTab(
|
||||||
|
title: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
badgeCount: Int?,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { isFocused = it.isFocused },
|
||||||
|
colors = CardDefaults.colors(
|
||||||
|
containerColor = when {
|
||||||
|
isSelected -> NuvioColors.Primary
|
||||||
|
isFocused -> NuvioColors.SurfaceVariant
|
||||||
|
else -> NuvioColors.Background
|
||||||
|
},
|
||||||
|
focusedContainerColor = if (isSelected) NuvioColors.Primary else NuvioColors.SurfaceVariant
|
||||||
|
),
|
||||||
|
shape = CardDefaults.shape(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isSelected) Color.White else NuvioColors.TextPrimary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Badge for addon count
|
||||||
|
if (badgeCount != null && badgeCount > 0) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (isSelected) Color.White.copy(alpha = 0.2f) else NuvioColors.Primary)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = badgeCount.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InternalSubtitlesContent(
|
||||||
|
tracks: List<TrackInfo>,
|
||||||
|
selectedIndex: Int,
|
||||||
|
selectedAddonSubtitle: Subtitle?,
|
||||||
|
onTrackSelected: (Int) -> Unit,
|
||||||
|
onDisableSubtitles: () -> Unit
|
||||||
|
) {
|
||||||
|
TvLazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(top = 4.dp),
|
||||||
|
modifier = Modifier.height(300.dp)
|
||||||
|
) {
|
||||||
|
// Off option
|
||||||
|
item {
|
||||||
|
TrackItem(
|
||||||
|
track = TrackInfo(index = -1, name = "Off", language = null),
|
||||||
|
isSelected = selectedIndex == -1 && selectedAddonSubtitle == null,
|
||||||
|
onClick = onDisableSubtitles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No internal subtitles available",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = NuvioColors.TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(tracks) { track ->
|
||||||
|
TrackItem(
|
||||||
|
track = track,
|
||||||
|
isSelected = track.index == selectedIndex && selectedAddonSubtitle == null,
|
||||||
|
onClick = { onTrackSelected(track.index) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddonSubtitlesContent(
|
||||||
|
subtitles: List<Subtitle>,
|
||||||
|
selectedSubtitle: Subtitle?,
|
||||||
|
isLoading: Boolean,
|
||||||
|
onSubtitleSelected: (Subtitle) -> Unit
|
||||||
|
) {
|
||||||
|
TvLazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(top = 4.dp),
|
||||||
|
modifier = Modifier.height(300.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
Text(
|
||||||
|
text = "Loading subtitles from addons...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = NuvioColors.TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (subtitles.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No addon subtitles available",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = NuvioColors.TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(subtitles) { subtitle ->
|
||||||
|
AddonSubtitleItem(
|
||||||
|
subtitle = subtitle,
|
||||||
|
isSelected = selectedSubtitle?.id == subtitle.id,
|
||||||
|
onClick = { onSubtitleSelected(subtitle) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddonSubtitleItem(
|
||||||
|
subtitle: Subtitle,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { isFocused = it.isFocused },
|
||||||
|
colors = CardDefaults.colors(
|
||||||
|
containerColor = when {
|
||||||
|
isSelected -> NuvioColors.Primary.copy(alpha = 0.3f)
|
||||||
|
isFocused -> NuvioColors.SurfaceVariant
|
||||||
|
else -> NuvioColors.Background
|
||||||
|
},
|
||||||
|
focusedContainerColor = if (isSelected) NuvioColors.Primary.copy(alpha = 0.5f) else NuvioColors.SurfaceVariant
|
||||||
|
),
|
||||||
|
shape = CardDefaults.shape(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = subtitle.getDisplayLanguage(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = NuvioColors.TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle.addonName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = NuvioColors.TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = NuvioColors.Primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.media3.common.TrackGroup
|
||||||
import com.nuvio.tv.data.local.SubtitleStyleSettings
|
import com.nuvio.tv.data.local.SubtitleStyleSettings
|
||||||
import com.nuvio.tv.data.repository.SkipInterval
|
import com.nuvio.tv.data.repository.SkipInterval
|
||||||
import com.nuvio.tv.domain.model.Stream
|
import com.nuvio.tv.domain.model.Stream
|
||||||
|
import com.nuvio.tv.domain.model.Subtitle
|
||||||
import com.nuvio.tv.domain.model.Video
|
import com.nuvio.tv.domain.model.Video
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
|
|
@ -27,6 +28,11 @@ data class PlayerUiState(
|
||||||
val showSpeedDialog: Boolean = false,
|
val showSpeedDialog: Boolean = false,
|
||||||
// Subtitle style settings
|
// Subtitle style settings
|
||||||
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(),
|
val subtitleStyle: SubtitleStyleSettings = SubtitleStyleSettings(),
|
||||||
|
// Addon subtitles
|
||||||
|
val addonSubtitles: List<Subtitle> = emptyList(),
|
||||||
|
val isLoadingAddonSubtitles: Boolean = false,
|
||||||
|
val selectedAddonSubtitle: Subtitle? = null,
|
||||||
|
val addonSubtitlesError: String? = null,
|
||||||
// Episodes/streams side panel (for series)
|
// Episodes/streams side panel (for series)
|
||||||
val showEpisodesPanel: Boolean = false,
|
val showEpisodesPanel: Boolean = false,
|
||||||
val isLoadingEpisodes: Boolean = false,
|
val isLoadingEpisodes: Boolean = false,
|
||||||
|
|
@ -83,6 +89,7 @@ sealed class PlayerEvent {
|
||||||
data class OnSelectAudioTrack(val index: Int) : PlayerEvent()
|
data class OnSelectAudioTrack(val index: Int) : PlayerEvent()
|
||||||
data class OnSelectSubtitleTrack(val index: Int) : PlayerEvent()
|
data class OnSelectSubtitleTrack(val index: Int) : PlayerEvent()
|
||||||
data object OnDisableSubtitles : PlayerEvent()
|
data object OnDisableSubtitles : PlayerEvent()
|
||||||
|
data class OnSelectAddonSubtitle(val subtitle: Subtitle) : PlayerEvent()
|
||||||
data class OnSetPlaybackSpeed(val speed: Float) : PlayerEvent()
|
data class OnSetPlaybackSpeed(val speed: Float) : PlayerEvent()
|
||||||
data object OnToggleControls : PlayerEvent()
|
data object OnToggleControls : PlayerEvent()
|
||||||
data object OnShowAudioDialog : PlayerEvent()
|
data object OnShowAudioDialog : PlayerEvent()
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class PlayerViewModel @Inject constructor(
|
||||||
private val watchProgressRepository: WatchProgressRepository,
|
private val watchProgressRepository: WatchProgressRepository,
|
||||||
private val metaRepository: MetaRepository,
|
private val metaRepository: MetaRepository,
|
||||||
private val streamRepository: StreamRepository,
|
private val streamRepository: StreamRepository,
|
||||||
|
private val subtitleRepository: com.nuvio.tv.domain.repository.SubtitleRepository,
|
||||||
private val parentalGuideRepository: ParentalGuideRepository,
|
private val parentalGuideRepository: ParentalGuideRepository,
|
||||||
private val skipIntroRepository: SkipIntroRepository,
|
private val skipIntroRepository: SkipIntroRepository,
|
||||||
private val playerSettingsDataStore: PlayerSettingsDataStore,
|
private val playerSettingsDataStore: PlayerSettingsDataStore,
|
||||||
|
|
@ -143,6 +144,7 @@ class PlayerViewModel @Inject constructor(
|
||||||
private var skipIntervals: List<SkipInterval> = emptyList()
|
private var skipIntervals: List<SkipInterval> = emptyList()
|
||||||
private var lastActiveSkipType: String? = null
|
private var lastActiveSkipType: String? = null
|
||||||
private var autoSubtitleSelected: Boolean = false
|
private var autoSubtitleSelected: Boolean = false
|
||||||
|
private var pendingAddonSubtitleLanguage: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initializePlayer(currentStreamUrl, currentHeaders)
|
initializePlayer(currentStreamUrl, currentHeaders)
|
||||||
|
|
@ -150,6 +152,45 @@ class PlayerViewModel @Inject constructor(
|
||||||
fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode)
|
fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode)
|
||||||
fetchSkipIntervals(contentId, currentSeason, currentEpisode)
|
fetchSkipIntervals(contentId, currentSeason, currentEpisode)
|
||||||
observeSubtitleSettings()
|
observeSubtitleSettings()
|
||||||
|
fetchAddonSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAddonSubtitles() {
|
||||||
|
val id = contentId ?: return
|
||||||
|
val type = contentType ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoadingAddonSubtitles = true, addonSubtitlesError = null) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For series, construct videoId with season:episode
|
||||||
|
val videoId = if (type == "series" && currentSeason != null && currentEpisode != null) {
|
||||||
|
"${id.split(":").firstOrNull() ?: id}:$currentSeason:$currentEpisode"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val subtitles = subtitleRepository.getSubtitles(
|
||||||
|
type = type,
|
||||||
|
id = id.split(":").firstOrNull() ?: id, // Use base IMDB ID
|
||||||
|
videoId = videoId
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
addonSubtitles = subtitles,
|
||||||
|
isLoadingAddonSubtitles = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoadingAddonSubtitles = false,
|
||||||
|
addonSubtitlesError = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeSubtitleSettings() {
|
private fun observeSubtitleSettings() {
|
||||||
|
|
@ -912,15 +953,23 @@ class PlayerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSubtitleIndex == -1 && subtitleTracks.isNotEmpty() && !autoSubtitleSelected) {
|
fun matchesLanguage(track: TrackInfo, target: String): Boolean {
|
||||||
|
val lang = track.language?.lowercase() ?: return false
|
||||||
|
return lang == target || lang.startsWith(target) || lang.contains(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingLang = pendingAddonSubtitleLanguage
|
||||||
|
if (pendingLang != null && subtitleTracks.isNotEmpty()) {
|
||||||
|
val preferredIndex = subtitleTracks.indexOfFirst { matchesLanguage(it, pendingLang) }
|
||||||
|
val fallbackIndex = if (preferredIndex >= 0) preferredIndex else 0
|
||||||
|
|
||||||
|
selectSubtitleTrack(fallbackIndex)
|
||||||
|
selectedSubtitleIndex = if (_uiState.value.selectedAddonSubtitle != null) -1 else fallbackIndex
|
||||||
|
pendingAddonSubtitleLanguage = null
|
||||||
|
} else if (selectedSubtitleIndex == -1 && subtitleTracks.isNotEmpty() && !autoSubtitleSelected) {
|
||||||
val preferred = _uiState.value.subtitleStyle.preferredLanguage.lowercase()
|
val preferred = _uiState.value.subtitleStyle.preferredLanguage.lowercase()
|
||||||
val secondary = _uiState.value.subtitleStyle.secondaryPreferredLanguage?.lowercase()
|
val secondary = _uiState.value.subtitleStyle.secondaryPreferredLanguage?.lowercase()
|
||||||
|
|
||||||
fun matchesLanguage(track: TrackInfo, target: String): Boolean {
|
|
||||||
val lang = track.language?.lowercase() ?: return false
|
|
||||||
return lang == target || lang.startsWith(target) || lang.contains(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
val preferredMatch = subtitleTracks.indexOfFirst { matchesLanguage(it, preferred) }
|
val preferredMatch = subtitleTracks.indexOfFirst { matchesLanguage(it, preferred) }
|
||||||
val secondaryMatch = secondary?.let { target ->
|
val secondaryMatch = secondary?.let { target ->
|
||||||
subtitleTracks.indexOfFirst { matchesLanguage(it, target) }
|
subtitleTracks.indexOfFirst { matchesLanguage(it, target) }
|
||||||
|
|
@ -1101,10 +1150,25 @@ class PlayerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
is PlayerEvent.OnSelectSubtitleTrack -> {
|
is PlayerEvent.OnSelectSubtitleTrack -> {
|
||||||
selectSubtitleTrack(event.index)
|
selectSubtitleTrack(event.index)
|
||||||
_uiState.update { it.copy(showSubtitleDialog = false) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
showSubtitleDialog = false,
|
||||||
|
selectedAddonSubtitle = null // Clear addon subtitle when selecting internal
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PlayerEvent.OnDisableSubtitles -> {
|
PlayerEvent.OnDisableSubtitles -> {
|
||||||
disableSubtitles()
|
disableSubtitles()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
showSubtitleDialog = false,
|
||||||
|
selectedAddonSubtitle = null,
|
||||||
|
selectedSubtitleTrackIndex = -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PlayerEvent.OnSelectAddonSubtitle -> {
|
||||||
|
selectAddonSubtitle(event.subtitle)
|
||||||
_uiState.update { it.copy(showSubtitleDialog = false) }
|
_uiState.update { it.copy(showSubtitleDialog = false) }
|
||||||
}
|
}
|
||||||
is PlayerEvent.OnSetPlaybackSpeed -> {
|
is PlayerEvent.OnSetPlaybackSpeed -> {
|
||||||
|
|
@ -1281,6 +1345,105 @@ class PlayerViewModel @Inject constructor(
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectAddonSubtitle(subtitle: com.nuvio.tv.domain.model.Subtitle) {
|
||||||
|
_exoPlayer?.let { player ->
|
||||||
|
if (_uiState.value.selectedAddonSubtitle?.id == subtitle.id) {
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedLang = normalizeLanguageCode(subtitle.lang)
|
||||||
|
pendingAddonSubtitleLanguage = normalizedLang
|
||||||
|
|
||||||
|
// Add the addon subtitle as a side-loaded subtitle
|
||||||
|
val currentItem = player.currentMediaItem ?: return@let
|
||||||
|
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(
|
||||||
|
android.net.Uri.parse(subtitle.url)
|
||||||
|
)
|
||||||
|
.setMimeType(getMimeTypeFromUrl(subtitle.url))
|
||||||
|
.setLanguage(subtitle.lang)
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val newMediaItem = currentItem.buildUpon()
|
||||||
|
.setSubtitleConfigurations(listOf(subtitleConfig))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val currentPosition = player.currentPosition
|
||||||
|
val playWhenReady = player.playWhenReady
|
||||||
|
|
||||||
|
player.setMediaItem(newMediaItem, currentPosition)
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = playWhenReady
|
||||||
|
|
||||||
|
// Ensure text tracks are enabled and prefer the addon subtitle language
|
||||||
|
player.trackSelectionParameters = player.trackSelectionParameters
|
||||||
|
.buildUpon()
|
||||||
|
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||||
|
.setPreferredTextLanguage(normalizedLang)
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedAddonSubtitle = subtitle,
|
||||||
|
selectedSubtitleTrackIndex = -1 // Clear internal track selection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeLanguageCode(lang: String): String {
|
||||||
|
val code = lang.lowercase()
|
||||||
|
return when (code) {
|
||||||
|
"eng" -> "en"
|
||||||
|
"spa" -> "es"
|
||||||
|
"fre", "fra" -> "fr"
|
||||||
|
"ger", "deu" -> "de"
|
||||||
|
"ita" -> "it"
|
||||||
|
"por" -> "pt"
|
||||||
|
"rus" -> "ru"
|
||||||
|
"jpn" -> "ja"
|
||||||
|
"kor" -> "ko"
|
||||||
|
"chi", "zho" -> "zh"
|
||||||
|
"ara" -> "ar"
|
||||||
|
"hin" -> "hi"
|
||||||
|
"nld", "dut" -> "nl"
|
||||||
|
"pol" -> "pl"
|
||||||
|
"swe" -> "sv"
|
||||||
|
"nor" -> "no"
|
||||||
|
"dan" -> "da"
|
||||||
|
"fin" -> "fi"
|
||||||
|
"tur" -> "tr"
|
||||||
|
"ell", "gre" -> "el"
|
||||||
|
"heb" -> "he"
|
||||||
|
"tha" -> "th"
|
||||||
|
"vie" -> "vi"
|
||||||
|
"ind" -> "id"
|
||||||
|
"msa", "may" -> "ms"
|
||||||
|
"ces", "cze" -> "cs"
|
||||||
|
"hun" -> "hu"
|
||||||
|
"ron", "rum" -> "ro"
|
||||||
|
"ukr" -> "uk"
|
||||||
|
"bul" -> "bg"
|
||||||
|
"hrv" -> "hr"
|
||||||
|
"srp" -> "sr"
|
||||||
|
"slk", "slo" -> "sk"
|
||||||
|
"slv" -> "sl"
|
||||||
|
else -> code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMimeTypeFromUrl(url: String): String {
|
||||||
|
val lowerUrl = url.lowercase()
|
||||||
|
return when {
|
||||||
|
lowerUrl.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
lowerUrl.endsWith(".vtt") || lowerUrl.endsWith(".webvtt") -> MimeTypes.TEXT_VTT
|
||||||
|
lowerUrl.endsWith(".ass") || lowerUrl.endsWith(".ssa") -> MimeTypes.TEXT_SSA
|
||||||
|
lowerUrl.endsWith(".ttml") || lowerUrl.endsWith(".dfxp") -> MimeTypes.APPLICATION_TTML
|
||||||
|
else -> MimeTypes.APPLICATION_SUBRIP // Default to SRT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun releasePlayer() {
|
private fun releasePlayer() {
|
||||||
// Save progress before releasing
|
// Save progress before releasing
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue