feat: implement subtitle management with addon support

This commit is contained in:
tapframe 2026-02-05 17:55:38 +05:30
parent eccc730efa
commit aed09c07a8
10 changed files with 780 additions and 32 deletions

3
.gitignore vendored
View file

@ -13,4 +13,5 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
libass-android libass-android
Stremio addons refer

View file

@ -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

View file

@ -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>
} }

View file

@ -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
)

View file

@ -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 {
""
}
}
}

View 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()
}
}
}

View file

@ -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>
}

View file

@ -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

View file

@ -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()

View file

@ -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