merge confligt .gitignore

This commit is contained in:
CrissZollo 2026-02-20 23:53:15 +01:00
commit f2bdfb2f55
No known key found for this signature in database
16 changed files with 910 additions and 411 deletions

6
.gitignore vendored
View file

@ -33,4 +33,8 @@ scripts/live_app_logs.sh
docs/tv_qr_login.sql
docss
benchmark
.mcp.json
.mcp.json
db/
backups/
.supabase_db.env
scripts

View file

@ -138,22 +138,23 @@ configurations.all {
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
baselineProfile(project(":benchmark"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.profileinstaller)
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation(platform(libs.androidx.compose.bom))
implementation(composeBom)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
implementation("androidx.tv:tv-material:1.0.1")
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation("androidx.activity:activity-compose:1.11.0")
// Hilt
implementation(libs.hilt.android)
@ -248,6 +249,6 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("io.mockk:mockk:1.13.12")
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -60,7 +60,7 @@ fun NuvioNavHost(
if (isStreamToPlayer(from, to) && isAutoPlayNav) {
EnterTransition.None
} else {
fadeIn(animationSpec = tween(150))
fadeIn(animationSpec = tween(350))
}
},
exitTransition = {
@ -72,7 +72,7 @@ fun NuvioNavHost(
if (isStreamToPlayer(from, to) && isAutoPlayNav) {
ExitTransition.None
} else {
fadeOut(animationSpec = tween(150))
fadeOut(animationSpec = tween(350))
}
},
popEnterTransition = {
@ -84,7 +84,7 @@ fun NuvioNavHost(
if (isPlayerToStream(from, to) && isAutoPlayNav) {
EnterTransition.None
} else {
fadeIn(animationSpec = tween(150))
fadeIn(animationSpec = tween(350))
}
},
popExitTransition = {
@ -96,7 +96,7 @@ fun NuvioNavHost(
if (isPlayerToStream(from, to) && isAutoPlayNav) {
ExitTransition.None
} else {
fadeOut(animationSpec = tween(150))
fadeOut(animationSpec = tween(350))
}
}
) {

View file

@ -16,6 +16,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -30,10 +34,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvGridItemSpan
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState
import androidx.tv.material3.Border
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
@ -65,7 +65,7 @@ fun GridHomeContent(
posterCardStyle: PosterCardStyle = PosterCardDefaults.Style,
onSaveGridFocusState: (Int, Int) -> Unit
) {
val gridState = rememberTvLazyGridState(
val gridState = rememberLazyGridState(
initialFirstVisibleItemIndex = gridFocusState.verticalScrollIndex,
initialFirstVisibleItemScrollOffset = gridFocusState.verticalScrollOffset
)
@ -143,9 +143,9 @@ fun GridHomeContent(
}
Box(modifier = Modifier.fillMaxSize()) {
TvLazyVerticalGrid(
LazyVerticalGrid(
state = gridState,
columns = TvGridCells.Adaptive(minSize = posterCardStyle.width),
columns = GridCells.Adaptive(minSize = posterCardStyle.width),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 24.dp,
@ -164,7 +164,7 @@ fun GridHomeContent(
is GridItem.Hero -> {
item(
key = "hero",
span = { TvGridItemSpan(maxLineSpan) },
span = { GridItemSpan(maxLineSpan) },
contentType = "hero"
) {
HeroCarousel(
@ -187,7 +187,7 @@ fun GridHomeContent(
continueWatchingInserted = true
item(
key = "continue_watching",
span = { TvGridItemSpan(maxLineSpan) },
span = { GridItemSpan(maxLineSpan) },
contentType = "continue_watching"
) {
GridContinueWatchingSection(
@ -231,7 +231,7 @@ fun GridHomeContent(
item(
key = "divider_${index}_${gridItem.catalogId}_${gridItem.addonId}_${gridItem.type}",
span = { TvGridItemSpan(maxLineSpan) },
span = { GridItemSpan(maxLineSpan) },
contentType = "divider"
) {
SectionDivider(
@ -254,7 +254,7 @@ fun GridHomeContent(
}
item(
key = "content_${index}_${gridItem.catalogId}_${gridItem.item.id}",
span = { TvGridItemSpan(1) },
span = { GridItemSpan(1) },
contentType = "content"
) {
GridContentCard(
@ -287,7 +287,7 @@ fun GridHomeContent(
}
item(
key = "see_all_${gridItem.catalogId}_${gridItem.addonId}_${gridItem.type}",
span = { TvGridItemSpan(1) },
span = { GridItemSpan(1) },
contentType = "see_all"
) {
SeeAllGridCard(
@ -309,7 +309,7 @@ fun GridHomeContent(
if (!continueWatchingInserted && uiState.continueWatchingItems.isNotEmpty()) {
item(
key = "continue_watching_fallback",
span = { TvGridItemSpan(maxLineSpan) },
span = { GridItemSpan(maxLineSpan) },
contentType = "continue_watching"
) {
GridContinueWatchingSection(

View file

@ -1362,7 +1362,7 @@ class HomeViewModel @Inject constructor(
focusedItemIndex: Int,
catalogRowScrollStates: Map<String, Int>
) {
_focusState.value = HomeScreenFocusState(
val nextState = HomeScreenFocusState(
verticalScrollIndex = verticalScrollIndex,
verticalScrollOffset = verticalScrollOffset,
focusedRowIndex = focusedRowIndex,
@ -1370,6 +1370,8 @@ class HomeViewModel @Inject constructor(
catalogRowScrollStates = catalogRowScrollStates,
hasSavedFocus = true
)
if (_focusState.value == nextState) return
_focusState.value = nextState
}
/**

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
class PlayerRuntimeController(
internal val context: Context,
@ -128,6 +129,7 @@ class PlayerRuntimeController(
internal var frameRateProbeToken: Long = 0L
internal var hideAspectRatioIndicatorJob: Job? = null
internal var hideStreamSourceIndicatorJob: Job? = null
internal var hideSubtitleDelayOverlayJob: Job? = null
internal var nextEpisodeAutoPlayJob: Job? = null
internal var sourceStreamsJob: Job? = null
internal var sourceStreamsCacheRequestKey: String? = null
@ -173,6 +175,7 @@ class PlayerRuntimeController(
internal var pauseOverlayJob: Job? = null
internal val pauseOverlayDelayMs = 5000L
internal val seekProgressSyncDebounceMs = 700L
internal val subtitleDelayUs = AtomicLong(0L)
internal var pendingPreviewSeekPosition: Long? = null
internal var pendingResumeProgress: WatchProgress? = null
internal var hasRetriedCurrentStreamAfter416: Boolean = false

View file

@ -14,7 +14,10 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.ForwardingRenderer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory
@ -103,7 +106,11 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val renderersFactory = DefaultRenderersFactory(context)
subtitleDelayUs.set(_uiState.value.subtitleDelayMs.toLong() * 1000L)
val renderersFactory = SubtitleOffsetRenderersFactory(
context = context,
subtitleDelayUsProvider = subtitleDelayUs::get
)
.setExtensionRendererMode(playerSettings.decoderPriority)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@ -318,3 +325,34 @@ internal fun PlayerRuntimeController.resetLoadingOverlayForNewStream() {
)
}
}
private class SubtitleOffsetRenderersFactory(
context: Context,
private val subtitleDelayUsProvider: () -> Long
) : DefaultRenderersFactory(context) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: android.os.Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>
) {
val startIndex = out.size
super.buildTextRenderers(context, output, outputLooper, extensionRendererMode, out)
for (index in startIndex until out.size) {
out[index] = SubtitleOffsetRenderer(out[index], subtitleDelayUsProvider)
}
}
}
private class SubtitleOffsetRenderer(
renderer: Renderer,
private val subtitleDelayUsProvider: () -> Long
) : ForwardingRenderer(renderer) {
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
val adjustedPositionUs = (positionUs - subtitleDelayUsProvider()).coerceAtLeast(0L)
super.render(adjustedPositionUs, elapsedRealtimeUs)
}
}

View file

@ -26,6 +26,7 @@ internal fun PlayerRuntimeController.releasePlayer() {
seekProgressSyncJob?.cancel()
frameRateProbeJob?.cancel()
hideStreamSourceIndicatorJob?.cancel()
hideSubtitleDelayOverlayJob?.cancel()
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlayJob = null
_exoPlayer?.release()

View file

@ -254,12 +254,60 @@ fun PlayerRuntimeController.scheduleHideControls() {
if (_uiState.value.isPlaying && !_uiState.value.showAudioDialog &&
!_uiState.value.showSubtitleDialog && !_uiState.value.showSubtitleStylePanel &&
!_uiState.value.showSpeedDialog && !_uiState.value.showMoreDialog &&
!_uiState.value.showSubtitleDelayOverlay &&
!_uiState.value.showEpisodesPanel && !_uiState.value.showSourcesPanel) {
_uiState.update { it.copy(showControls = false) }
}
}
}
internal fun PlayerRuntimeController.showSubtitleDelayOverlay() {
hideControlsJob?.cancel()
_uiState.update {
it.copy(
showControls = false,
showSubtitleDelayOverlay = true,
showAudioDialog = false,
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSpeedDialog = false
)
}
scheduleHideSubtitleDelayOverlay()
}
internal fun PlayerRuntimeController.hideSubtitleDelayOverlay() {
hideSubtitleDelayOverlayJob?.cancel()
hideSubtitleDelayOverlayJob = null
_uiState.update { it.copy(showSubtitleDelayOverlay = false) }
}
internal fun PlayerRuntimeController.adjustSubtitleDelay(deltaMs: Int) {
val currentDelayMs = _uiState.value.subtitleDelayMs
val newDelayMs = (currentDelayMs + deltaMs).coerceIn(
minimumValue = SUBTITLE_DELAY_MIN_MS,
maximumValue = SUBTITLE_DELAY_MAX_MS
)
subtitleDelayUs.set(newDelayMs.toLong() * 1000L)
_uiState.update {
it.copy(
subtitleDelayMs = newDelayMs,
showControls = false,
showSubtitleDelayOverlay = true
)
}
scheduleHideSubtitleDelayOverlay()
}
internal fun PlayerRuntimeController.scheduleHideSubtitleDelayOverlay() {
hideSubtitleDelayOverlayJob?.cancel()
hideSubtitleDelayOverlayJob = scope.launch {
delay(SUBTITLE_DELAY_OVERLAY_TIMEOUT_MS)
_uiState.update { it.copy(showSubtitleDelayOverlay = false) }
}
}
internal fun PlayerRuntimeController.schedulePauseOverlay() {
pauseOverlayJob?.cancel()
@ -378,7 +426,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
}
is PlayerEvent.OnSelectAudioTrack -> {
selectAudioTrack(event.index)
_uiState.update { it.copy(showAudioDialog = false) }
_uiState.update { it.copy(showAudioDialog = false, showSubtitleDelayOverlay = false) }
}
is PlayerEvent.OnSelectSubtitleTrack -> {
autoSubtitleSelected = true
@ -390,6 +438,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
it.copy(
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSubtitleDelayOverlay = false,
selectedAddonSubtitle = null
)
}
@ -404,6 +453,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
it.copy(
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSubtitleDelayOverlay = false,
selectedAddonSubtitle = null,
selectedSubtitleTrackIndex = -1
)
@ -412,15 +462,28 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
is PlayerEvent.OnSelectAddonSubtitle -> {
autoSubtitleSelected = true
selectAddonSubtitle(event.subtitle)
_uiState.update { it.copy(showSubtitleDialog = false, showSubtitleStylePanel = false) }
_uiState.update {
it.copy(
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSubtitleDelayOverlay = false
)
}
}
is PlayerEvent.OnSetPlaybackSpeed -> {
_exoPlayer?.setPlaybackSpeed(event.speed)
_uiState.update {
it.copy(playbackSpeed = event.speed, showSpeedDialog = false)
it.copy(
playbackSpeed = event.speed,
showSpeedDialog = false,
showSubtitleDelayOverlay = false
)
}
}
PlayerEvent.OnToggleControls -> {
if (_uiState.value.showSubtitleDelayOverlay) {
hideSubtitleDelayOverlay()
}
_uiState.update { it.copy(showControls = !it.showControls) }
if (_uiState.value.showControls) {
scheduleHideControls()
@ -432,6 +495,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
showAudioDialog = true,
showSubtitleStylePanel = false,
showMoreDialog = false,
showSubtitleDelayOverlay = false,
showControls = true
)
}
@ -442,6 +506,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
showSubtitleDialog = true,
showSubtitleStylePanel = false,
showMoreDialog = false,
showSubtitleDelayOverlay = false,
showControls = true
)
}
@ -452,6 +517,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
showSubtitleDialog = false,
showSubtitleStylePanel = true,
showMoreDialog = false,
showSubtitleDelayOverlay = false,
showControls = true
)
}
@ -460,12 +526,22 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
_uiState.update { it.copy(showSubtitleStylePanel = false) }
scheduleHideControls()
}
PlayerEvent.OnShowSubtitleDelayOverlay -> {
showSubtitleDelayOverlay()
}
PlayerEvent.OnHideSubtitleDelayOverlay -> {
hideSubtitleDelayOverlay()
}
is PlayerEvent.OnAdjustSubtitleDelay -> {
adjustSubtitleDelay(event.deltaMs)
}
PlayerEvent.OnShowSpeedDialog -> {
_uiState.update {
it.copy(
showSpeedDialog = true,
showSubtitleStylePanel = false,
showMoreDialog = false,
showSubtitleDelayOverlay = false,
showControls = true
)
}
@ -477,6 +553,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
showAudioDialog = false,
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSubtitleDelayOverlay = false,
showSpeedDialog = false,
showControls = true
)
@ -537,6 +614,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
showSubtitleDialog = false,
showSubtitleStylePanel = false,
showSpeedDialog = false,
showSubtitleDelayOverlay = false,
showMoreDialog = false
)
}
@ -549,7 +627,8 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
_uiState.update { state ->
state.copy(
error = null,
showLoadingOverlay = state.loadingOverlayEnabled
showLoadingOverlay = state.loadingOverlayEnabled,
showSubtitleDelayOverlay = false
)
}
releasePlayer()

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -66,6 +68,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@ -97,6 +100,7 @@ import com.nuvio.tv.R
import com.nuvio.tv.core.player.ExternalPlayerLauncher
import com.nuvio.tv.ui.components.LoadingIndicator
import com.nuvio.tv.ui.theme.NuvioColors
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.launch
@ -123,6 +127,8 @@ fun PlayerScreen(
viewModel.onEvent(PlayerEvent.OnDismissPauseOverlay)
} else if (uiState.showMoreDialog) {
viewModel.onEvent(PlayerEvent.OnDismissMoreDialog)
} else if (uiState.showSubtitleDelayOverlay) {
viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay)
} else if (uiState.showSubtitleStylePanel) {
viewModel.onEvent(PlayerEvent.OnDismissSubtitleStylePanel)
} else if (uiState.showSourcesPanel) {
@ -266,6 +272,7 @@ fun PlayerScreen(
uiState.showEpisodesPanel,
uiState.showSourcesPanel,
uiState.showSubtitleStylePanel,
uiState.showSubtitleDelayOverlay,
uiState.showAudioDialog,
uiState.showSubtitleDialog,
uiState.showSpeedDialog,
@ -273,8 +280,8 @@ fun PlayerScreen(
) {
if (uiState.showControls && !uiState.showEpisodesPanel && !uiState.showSourcesPanel &&
!uiState.showAudioDialog && !uiState.showSubtitleDialog &&
!uiState.showSubtitleStylePanel && !uiState.showSpeedDialog &&
!uiState.showMoreDialog
!uiState.showSubtitleStylePanel && !uiState.showSubtitleDelayOverlay &&
!uiState.showSpeedDialog && !uiState.showMoreDialog
) {
// Wait for AnimatedVisibility animation to complete before focusing play/pause button
kotlinx.coroutines.delay(250)
@ -309,12 +316,63 @@ fun PlayerScreen(
.background(Color.Black)
.focusRequester(containerFocusRequester)
.focusable()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.nativeKeyEvent.keyCode != KeyEvent.KEYCODE_CAPTIONS) {
return@onPreviewKeyEvent false
}
if (keyEvent.nativeKeyEvent.action != KeyEvent.ACTION_UP) {
return@onPreviewKeyEvent true
}
if (uiState.showSubtitleDelayOverlay) {
viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay)
} else if (
!uiState.showEpisodesPanel &&
!uiState.showSourcesPanel &&
!uiState.showAudioDialog &&
!uiState.showSubtitleDialog &&
!uiState.showSubtitleStylePanel &&
!uiState.showSpeedDialog
) {
viewModel.onEvent(PlayerEvent.OnShowSubtitleDialog)
}
true
}
.onKeyEvent { keyEvent ->
if (uiState.showSubtitleDelayOverlay) {
if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
when (keyEvent.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.onEvent(PlayerEvent.OnAdjustSubtitleDelay(-SUBTITLE_DELAY_STEP_MS))
return@onKeyEvent true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.onEvent(PlayerEvent.OnAdjustSubtitleDelay(SUBTITLE_DELAY_STEP_MS))
return@onKeyEvent true
}
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay)
return@onKeyEvent true
}
}
}
if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
(keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)
) {
return@onKeyEvent true
}
}
// When a side panel or dialog is open, let it handle all keys
val panelOrDialogOpen = uiState.showEpisodesPanel || uiState.showSourcesPanel ||
uiState.showAudioDialog || uiState.showSubtitleDialog ||
uiState.showSubtitleStylePanel || uiState.showSpeedDialog ||
uiState.showMoreDialog
uiState.showSubtitleDelayOverlay || uiState.showMoreDialog
if (panelOrDialogOpen) return@onKeyEvent false
if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
@ -558,6 +616,7 @@ fun PlayerScreen(
!uiState.showAudioDialog &&
!uiState.showSubtitleDialog &&
!uiState.showSubtitleStylePanel &&
!uiState.showSubtitleDelayOverlay &&
!uiState.showSpeedDialog &&
!uiState.showMoreDialog,
controlsVisible = uiState.showControls,
@ -600,6 +659,7 @@ fun PlayerScreen(
visible = uiState.showControls && uiState.error == null &&
!uiState.showLoadingOverlay && !uiState.showPauseOverlay &&
!uiState.showSubtitleStylePanel &&
!uiState.showSubtitleDelayOverlay &&
!uiState.showEpisodesPanel &&
!uiState.showSourcesPanel &&
!uiState.showAudioDialog &&
@ -654,10 +714,34 @@ fun PlayerScreen(
}
// Seek-only overlay (progress bar + time) when controls are hidden
AnimatedVisibility(
visible = uiState.showSubtitleDelayOverlay &&
!uiState.showControls &&
uiState.error == null &&
!uiState.showLoadingOverlay &&
!uiState.showPauseOverlay &&
!uiState.showSubtitleStylePanel &&
!uiState.showEpisodesPanel &&
!uiState.showSourcesPanel &&
!uiState.showAudioDialog &&
!uiState.showSubtitleDialog &&
!uiState.showSpeedDialog,
enter = fadeIn(animationSpec = tween(120)),
exit = fadeOut(animationSpec = tween(120)),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 44.dp)
.zIndex(2.3f)
) {
SubtitleDelayOverlay(
subtitleDelayMs = uiState.subtitleDelayMs
)
}
AnimatedVisibility(
visible = uiState.showSeekOverlay && !uiState.showControls && uiState.error == null &&
!uiState.showLoadingOverlay && !uiState.showPauseOverlay &&
!uiState.showMoreDialog,
!uiState.showSubtitleDelayOverlay && !uiState.showMoreDialog,
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(150)),
modifier = Modifier.align(Alignment.BottomCenter)
@ -807,6 +891,7 @@ fun PlayerScreen(
onAddonSubtitleSelected = { viewModel.onEvent(PlayerEvent.OnSelectAddonSubtitle(it)) },
onDisableSubtitles = { viewModel.onEvent(PlayerEvent.OnDisableSubtitles) },
onOpenStylePanel = { viewModel.onEvent(PlayerEvent.OnOpenSubtitleStylePanel) },
onOpenDelayOverlay = { viewModel.onEvent(PlayerEvent.OnShowSubtitleDelayOverlay) },
onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
)
}
@ -1265,6 +1350,91 @@ private fun StreamSourceIndicator(text: String) {
}
}
@Composable
private fun SubtitleDelayOverlay(subtitleDelayMs: Int) {
val fraction = ((subtitleDelayMs - SUBTITLE_DELAY_MIN_MS).toFloat() /
(SUBTITLE_DELAY_MAX_MS - SUBTITLE_DELAY_MIN_MS).toFloat()).coerceIn(0f, 1f)
Column(
modifier = Modifier
.fillMaxWidth(0.6f)
.clip(RoundedCornerShape(26.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xCC1F3246),
Color(0xCC283655),
Color(0xCC2F2B55)
)
)
)
.padding(horizontal = 26.dp, vertical = 20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Subtitle offset",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color.White
)
Text(
text = formatSubtitleDelay(subtitleDelayMs),
style = MaterialTheme.typography.titleLarge,
color = Color.White.copy(alpha = 0.95f)
)
}
Spacer(modifier = Modifier.height(18.dp))
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
) {
val thumbWidth = 22.dp
val thumbOffset = (maxWidth - thumbWidth) * fraction
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.align(Alignment.CenterStart)
.background(Color.White.copy(alpha = 0.15f))
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
repeat(5) { index ->
val tickHeight = if (index == 2) 13.dp else 9.dp
Box(
modifier = Modifier
.width(1.dp)
.height(tickHeight)
.background(Color.White.copy(alpha = 0.22f))
)
}
}
Box(
modifier = Modifier
.offset(x = thumbOffset)
.align(Alignment.CenterStart)
.width(thumbWidth)
.height(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.95f))
)
}
}
}
@Composable
private fun rememberRawSvgPainter(@RawRes iconRes: Int): Painter {
val context = LocalContext.current
@ -1527,3 +1697,7 @@ private fun formatTime(millis: Long): String {
String.format("%d:%02d", minutes, seconds)
}
}
private fun formatSubtitleDelay(delayMs: Int): String {
return String.format(Locale.US, "%+.3fs", delayMs / 1000f)
}

View file

@ -41,6 +41,8 @@ data class PlayerUiState(
val showAudioDialog: Boolean = false,
val showSubtitleDialog: Boolean = false,
val showSubtitleStylePanel: Boolean = false,
val showSubtitleDelayOverlay: Boolean = false,
val subtitleDelayMs: Int = 0,
val showSpeedDialog: Boolean = false,
val showMoreDialog: Boolean = false,
// Subtitle style settings
@ -156,6 +158,9 @@ sealed class PlayerEvent {
data object OnShowSubtitleDialog : PlayerEvent()
data object OnOpenSubtitleStylePanel : PlayerEvent()
data object OnDismissSubtitleStylePanel : PlayerEvent()
data object OnShowSubtitleDelayOverlay : PlayerEvent()
data object OnHideSubtitleDelayOverlay : PlayerEvent()
data class OnAdjustSubtitleDelay(val deltaMs: Int) : PlayerEvent()
data object OnShowSpeedDialog : PlayerEvent()
data object OnShowMoreDialog : PlayerEvent()
data object OnDismissMoreDialog : PlayerEvent()

View file

@ -0,0 +1,6 @@
package com.nuvio.tv.ui.screens.player
internal const val SUBTITLE_DELAY_MIN_MS = -60_000
internal const val SUBTITLE_DELAY_MAX_MS = 60_000
internal const val SUBTITLE_DELAY_STEP_MS = 100
internal const val SUBTITLE_DELAY_OVERLAY_TIMEOUT_MS = 5_000L

View file

@ -90,6 +90,7 @@ internal fun SubtitleSelectionDialog(
onAddonSubtitleSelected: (Subtitle) -> Unit,
onDisableSubtitles: () -> Unit,
onOpenStylePanel: () -> Unit,
onOpenDelayOverlay: () -> Unit,
onDismiss: () -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
@ -104,7 +105,7 @@ internal fun SubtitleSelectionDialog(
SubtitleOrganizationMode.NONE,
SubtitleOrganizationMode.BY_ADDON -> "Addons"
}
val tabs = listOf("Built-in", addonsTabTitle, "Style")
val tabs = listOf("Built-in", addonsTabTitle, "Style", "Delay")
val tabFocusRequesters = remember { tabs.map { FocusRequester() } }
Dialog(onDismissRequest = onDismiss) {
@ -132,13 +133,15 @@ internal fun SubtitleSelectionDialog(
.padding(bottom = 16.dp)
) {
tabs.forEachIndexed { index, _ ->
val onTabClick = if (index == 2) {
{
onOpenStylePanel()
val onTabClick = when (index) {
2 -> {
{ onOpenStylePanel() }
}
} else {
{
selectedTabIndex = index
3 -> {
{ onOpenDelayOverlay() }
}
else -> {
{ selectedTabIndex = index }
}
}
SubtitleTab(
@ -173,6 +176,7 @@ internal fun SubtitleSelectionDialog(
onSubtitleSelected = onAddonSubtitleSelected
)
2 -> Unit
3 -> Unit
}
}
}

View file

@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
@ -34,9 +37,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.itemsIndexed
import androidx.tv.material3.Border
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
@ -84,8 +84,8 @@ fun ThemeSettingsContent(
.fillMaxWidth()
.weight(1f)
) {
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(3),
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),

View file

@ -2,13 +2,13 @@
agp = "8.13.2"
cryptoJs = "4.2.0"
kotlin = "2.3.0"
coreKtx = "1.10.1"
coreKtx = "1.15.0"
appcompat = "1.6.1"
composeBom = "2025.01.01"
tvFoundation = "1.0.0-alpha11"
tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
activityCompose = "1.10.1"
# New dependencies
hilt = "2.58"
@ -18,7 +18,7 @@ okhttp = "4.12.0"
moshi = "1.15.1"
coroutines = "1.8.1"
coil = "2.7.0"
navigationCompose = "2.7.7"
navigationCompose = "2.8.8"
datastore = "1.1.1"
lifecycleViewModelCompose = "2.8.7"
ksp = "2.3.0"
@ -31,7 +31,7 @@ nanohttpd = "2.3.1"
zxing = "3.5.3"
supabase = "3.1.4"
ktor = "3.1.1"
kotlinxSerialization = "1.7.3"
kotlinxSerialization = "1.8.0"
benchmarkMacro = "1.4.1"
androidxTestExtJunit = "1.2.1"
androidxTestRunner = "1.6.2"