mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 09:35:42 +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
|
||||
.cxx
|
||||
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.MetaRepositoryImpl
|
||||
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.domain.repository.AddonRepository
|
||||
import com.nuvio.tv.domain.repository.CatalogRepository
|
||||
import com.nuvio.tv.domain.repository.MetaRepository
|
||||
import com.nuvio.tv.domain.repository.StreamRepository
|
||||
import com.nuvio.tv.domain.repository.SubtitleRepository
|
||||
import com.nuvio.tv.domain.repository.WatchProgressRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
|
@ -36,6 +38,10 @@ abstract class RepositoryModule {
|
|||
@Singleton
|
||||
abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSubtitleRepository(impl: SubtitleRepositoryImpl): SubtitleRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
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.MetaResponseDto
|
||||
import com.nuvio.tv.data.remote.dto.StreamResponseDto
|
||||
import com.nuvio.tv.data.remote.dto.SubtitleResponseDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Url
|
||||
|
|
@ -21,4 +22,7 @@ interface AddonApi {
|
|||
|
||||
@GET
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -77,6 +78,7 @@ import androidx.tv.material3.IconButton
|
|||
import androidx.tv.material3.IconButtonDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.nuvio.tv.domain.model.Subtitle
|
||||
import com.nuvio.tv.ui.components.LoadingIndicator
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
import com.nuvio.tv.ui.theme.NuvioTheme
|
||||
|
|
@ -450,9 +452,13 @@ fun PlayerScreen(
|
|||
// Subtitle track dialog
|
||||
if (uiState.showSubtitleDialog) {
|
||||
SubtitleSelectionDialog(
|
||||
tracks = uiState.subtitleTracks,
|
||||
selectedIndex = uiState.selectedSubtitleTrackIndex,
|
||||
onTrackSelected = { viewModel.onEvent(PlayerEvent.OnSelectSubtitleTrack(it)) },
|
||||
internalTracks = uiState.subtitleTracks,
|
||||
selectedInternalIndex = uiState.selectedSubtitleTrackIndex,
|
||||
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) },
|
||||
onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
|
||||
)
|
||||
|
|
@ -856,16 +862,24 @@ private fun TrackSelectionDialog(
|
|||
|
||||
@Composable
|
||||
private fun SubtitleSelectionDialog(
|
||||
tracks: List<TrackInfo>,
|
||||
selectedIndex: Int,
|
||||
onTrackSelected: (Int) -> Unit,
|
||||
internalTracks: List<TrackInfo>,
|
||||
selectedInternalIndex: Int,
|
||||
addonSubtitles: List<Subtitle>,
|
||||
selectedAddonSubtitle: Subtitle?,
|
||||
isLoadingAddons: Boolean,
|
||||
onInternalTrackSelected: (Int) -> Unit,
|
||||
onAddonSubtitleSelected: (Subtitle) -> Unit,
|
||||
onDisableSubtitles: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Internal", "Addons")
|
||||
val tabFocusRequesters = remember { tabs.map { FocusRequester() } }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.width(450.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NuvioColors.BackgroundElevated)
|
||||
) {
|
||||
|
|
@ -878,32 +892,282 @@ private fun SubtitleSelectionDialog(
|
|||
color = NuvioColors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
TvLazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(top = 4.dp),
|
||||
modifier = Modifier.height(300.dp)
|
||||
|
||||
// Tab row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
// Off option
|
||||
item {
|
||||
TrackItem(
|
||||
track = TrackInfo(index = -1, name = "Off", language = null),
|
||||
isSelected = selectedIndex == -1,
|
||||
onClick = onDisableSubtitles
|
||||
tabs.forEachIndexed { index, _ ->
|
||||
SubtitleTab(
|
||||
title = tabs[index],
|
||||
isSelected = selectedTabIndex == index,
|
||||
badgeCount = if (index == 1) addonSubtitles.size else null,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
items(tracks) { track ->
|
||||
TrackItem(
|
||||
track = track,
|
||||
isSelected = track.index == selectedIndex,
|
||||
onClick = { onTrackSelected(track.index) }
|
||||
1 -> {
|
||||
// Addon subtitles tab
|
||||
AddonSubtitlesContent(
|
||||
subtitles = addonSubtitles,
|
||||
selectedSubtitle = selectedAddonSubtitle,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.media3.common.TrackGroup
|
|||
import com.nuvio.tv.data.local.SubtitleStyleSettings
|
||||
import com.nuvio.tv.data.repository.SkipInterval
|
||||
import com.nuvio.tv.domain.model.Stream
|
||||
import com.nuvio.tv.domain.model.Subtitle
|
||||
import com.nuvio.tv.domain.model.Video
|
||||
|
||||
data class PlayerUiState(
|
||||
|
|
@ -27,6 +28,11 @@ data class PlayerUiState(
|
|||
val showSpeedDialog: Boolean = false,
|
||||
// Subtitle style settings
|
||||
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)
|
||||
val showEpisodesPanel: Boolean = false,
|
||||
val isLoadingEpisodes: Boolean = false,
|
||||
|
|
@ -83,6 +89,7 @@ sealed class PlayerEvent {
|
|||
data class OnSelectAudioTrack(val index: Int) : PlayerEvent()
|
||||
data class OnSelectSubtitleTrack(val index: Int) : PlayerEvent()
|
||||
data object OnDisableSubtitles : PlayerEvent()
|
||||
data class OnSelectAddonSubtitle(val subtitle: Subtitle) : PlayerEvent()
|
||||
data class OnSetPlaybackSpeed(val speed: Float) : PlayerEvent()
|
||||
data object OnToggleControls : PlayerEvent()
|
||||
data object OnShowAudioDialog : PlayerEvent()
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class PlayerViewModel @Inject constructor(
|
|||
private val watchProgressRepository: WatchProgressRepository,
|
||||
private val metaRepository: MetaRepository,
|
||||
private val streamRepository: StreamRepository,
|
||||
private val subtitleRepository: com.nuvio.tv.domain.repository.SubtitleRepository,
|
||||
private val parentalGuideRepository: ParentalGuideRepository,
|
||||
private val skipIntroRepository: SkipIntroRepository,
|
||||
private val playerSettingsDataStore: PlayerSettingsDataStore,
|
||||
|
|
@ -143,6 +144,7 @@ class PlayerViewModel @Inject constructor(
|
|||
private var skipIntervals: List<SkipInterval> = emptyList()
|
||||
private var lastActiveSkipType: String? = null
|
||||
private var autoSubtitleSelected: Boolean = false
|
||||
private var pendingAddonSubtitleLanguage: String? = null
|
||||
|
||||
init {
|
||||
initializePlayer(currentStreamUrl, currentHeaders)
|
||||
|
|
@ -150,6 +152,45 @@ class PlayerViewModel @Inject constructor(
|
|||
fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode)
|
||||
fetchSkipIntervals(contentId, currentSeason, currentEpisode)
|
||||
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() {
|
||||
|
|
@ -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 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 secondaryMatch = secondary?.let { target ->
|
||||
subtitleTracks.indexOfFirst { matchesLanguage(it, target) }
|
||||
|
|
@ -1101,10 +1150,25 @@ class PlayerViewModel @Inject constructor(
|
|||
}
|
||||
is PlayerEvent.OnSelectSubtitleTrack -> {
|
||||
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 -> {
|
||||
disableSubtitles()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showSubtitleDialog = false,
|
||||
selectedAddonSubtitle = null,
|
||||
selectedSubtitleTrackIndex = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
is PlayerEvent.OnSelectAddonSubtitle -> {
|
||||
selectAddonSubtitle(event.subtitle)
|
||||
_uiState.update { it.copy(showSubtitleDialog = false) }
|
||||
}
|
||||
is PlayerEvent.OnSetPlaybackSpeed -> {
|
||||
|
|
@ -1281,6 +1345,105 @@ class PlayerViewModel @Inject constructor(
|
|||
.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() {
|
||||
// Save progress before releasing
|
||||
|
|
|
|||
Loading…
Reference in a new issue