merge confligt .gitignore
This commit is contained in:
commit
f2bdfb2f55
16 changed files with 910 additions and 411 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ internal fun PlayerRuntimeController.releasePlayer() {
|
|||
seekProgressSyncJob?.cancel()
|
||||
frameRateProbeJob?.cancel()
|
||||
hideStreamSourceIndicatorJob?.cancel()
|
||||
hideSubtitleDelayOverlayJob?.cancel()
|
||||
nextEpisodeAutoPlayJob?.cancel()
|
||||
nextEpisodeAutoPlayJob = null
|
||||
_exoPlayer?.release()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue