From 3e22cceb6ef80e51a2bccc23934152aaa3e53ada Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:01:36 -0400 Subject: [PATCH] Add dual-screen live playback --- app/build.gradle.kts | 4 +- .../com/example/xtreamplayer/BrowseScreen.kt | 1 + .../example/xtreamplayer/DualLiveScreen.kt | 560 +++++++ .../example/xtreamplayer/DualLiveSession.kt | 13 + .../example/xtreamplayer/MainActivityUi.kt | 12 + .../xtreamplayer/MainActivityUiPlayer.kt | 1382 +++++++++++++---- .../com/example/xtreamplayer/PlayerScreen.kt | 5 + .../example/xtreamplayer/SettingsScreens.kt | 13 +- .../xtreamplayer/content/SearchPolicy.kt | 12 + .../player/Media3PlaybackEngine.kt | 36 +- .../xtreamplayer/player/XtreamPlayerView.kt | 23 + .../settings/SettingsRepository.kt | 11 +- .../xtreamplayer/settings/SettingsState.kt | 3 +- .../settings/SettingsViewModel.kt | 6 + .../example/xtreamplayer/ui/SearchDebounce.kt | 9 +- app/src/main/res/drawable/ic_dual_screen.xml | 10 + .../res/layout/exo_player_control_view.xml | 9 + .../main/res/values/strings_exo_controls.xml | 1 + .../content/SearchNormalizerTest.kt | 34 + .../xtreamplayer/content/SearchPolicyTest.kt | 43 + 20 files changed, 1864 insertions(+), 323 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/DualLiveScreen.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/DualLiveSession.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/content/SearchPolicy.kt create mode 100644 app/src/main/res/drawable/ic_dual_screen.xml create mode 100644 app/src/test/java/com/example/xtreamplayer/content/SearchPolicyTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b128c3e..9826c87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,8 +3,8 @@ import java.util.Properties import org.gradle.api.provider.Property import org.jetbrains.kotlin.gradle.dsl.JvmTarget -val appVersionCode = 149 -val appVersionName = "3.5.3" +val appVersionCode = 150 +val appVersionName = "3.5.4" plugins { alias(libs.plugins.android.application) diff --git a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt index 2d34ebe..0b0abde 100644 --- a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt @@ -498,6 +498,7 @@ Row(modifier = Modifier.fillMaxSize()) { onOpenSyncSchedule = { showSyncScheduleDialog = true }, onToggleCheckUpdatesOnStartup = onToggleCheckUpdatesOnStartup, onCheckForUpdates = { onCheckForUpdates() }, + onToggleDualLiveEnabled = settingsViewModel::toggleDualLiveEnabled, onClearCache = { coroutineScope.launch { val contentBytes = contentRepository.diskCacheSizeBytes() diff --git a/app/src/main/java/com/example/xtreamplayer/DualLiveScreen.kt b/app/src/main/java/com/example/xtreamplayer/DualLiveScreen.kt new file mode 100644 index 0000000..78ed1c6 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/DualLiveScreen.kt @@ -0,0 +1,560 @@ +package com.example.xtreamplayer + +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.ViewGroup +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.focusable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.example.xtreamplayer.content.ContentItem +import com.example.xtreamplayer.ui.theme.AppTheme +import kotlinx.coroutines.delay + +private const val LONG_PRESS_THRESHOLD_MS = 600L + +@OptIn(UnstableApi::class) +@Composable +internal fun DualLiveScreen( + primaryPlayer: Player, + secondaryPlayer: Player?, + primaryChannel: ContentItem?, + rightChannel: ContentItem?, + focusedTile: DualTile, + leftTileState: DualTilePlayerState = DualTilePlayerState.Ready, + rightTileState: DualTilePlayerState, + // Action menu state owned by parent so the parent BackHandler can dismiss it first. + actionMenuTile: DualTile?, + onOpenActionMenu: (DualTile) -> Unit, + onCloseActionMenu: () -> Unit, + onFocusChange: (DualTile) -> Unit, + onEnterFullscreen: (DualTile) -> Unit, + onOpenChannelPicker: (DualTile) -> Unit, + onCloseTile: (DualTile) -> Unit, + onRestartStream: (DualTile) -> Unit, + onRetryError: (DualTile) -> Unit, + onExitDualScreen: () -> Unit, + isGuideOpen: Boolean = false, + onGuideMove: (Int) -> Unit = {}, + onGuideSelect: () -> Unit = {}, + onGuideBack: () -> Unit = {}, + onGuideDismiss: () -> Unit = {}, + onGuideSearchKey: (KeyEvent) -> Boolean = { false }, + modifier: Modifier = Modifier +) { + val focusTrapRequester = remember { FocusRequester() } + + // Claim focus on mount (isGuideOpen starts false) and whenever the guide opens. + // Merged from the original separate LaunchedEffect(Unit) + LaunchedEffect(isGuideOpen). + LaunchedEffect(isGuideOpen) { + runCatching { focusTrapRequester.requestFocus() } + } + // After guide/menu close or when a new tile's AndroidView is composed, reclaim focus with + // an 80 ms delay so the freshly-added PlayerView can't steal focus during layout. + LaunchedEffect(isGuideOpen, actionMenuTile, primaryChannel?.id, rightChannel?.id) { + if (!isGuideOpen && actionMenuTile == null) { + delay(80) + runCatching { focusTrapRequester.requestFocus() } + } + } + + val keyHandler = remember { Handler(Looper.getMainLooper()) } + // Plain object refs — never drive recomposition, only read/written inside key handlers. + val longPress = remember { object { var runnable: Runnable? = null; var handled = false } } + val swallowOkUp = remember { object { var value = false } } + // Tracks whether the action menu was opened by long-OK (needs suppress) or Menu key (no suppress). + val menuOpenedViaLongPress = remember { object { var value = false } } + + fun cancelLongPress() { + longPress.runnable?.let { keyHandler.removeCallbacks(it) } + longPress.runnable = null + } + + fun scheduleLongPress(tile: DualTile) { + cancelLongPress() + longPress.handled = false + val runnable = Runnable { + longPress.runnable = null + longPress.handled = true + menuOpenedViaLongPress.value = true + onOpenActionMenu(tile) + } + longPress.runnable = runnable + keyHandler.postDelayed(runnable, LONG_PRESS_THRESHOLD_MS) + } + + DisposableEffect(Unit) { + onDispose { cancelLongPress() } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusTrapRequester) + .focusable() + .onPreviewKeyEvent { keyEvent -> + if (actionMenuTile != null) return@onPreviewKeyEvent false + if (isGuideOpen) { + if (keyEvent.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_ESCAPE -> true + else -> false + } + } + return@onPreviewKeyEvent when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { onGuideMove(-1); true } + KeyEvent.KEYCODE_DPAD_DOWN -> { onGuideMove(1); true } + KeyEvent.KEYCODE_DPAD_LEFT -> { onGuideBack(); true } + KeyEvent.KEYCODE_DPAD_RIGHT -> true + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + swallowOkUp.value = true + onGuideSelect() + true + } + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_ESCAPE -> { onGuideDismiss(); true } + else -> onGuideSearchKey(keyEvent.nativeKeyEvent) + } + } + when (keyEvent.type) { + KeyEventType.KeyDown -> when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (focusedTile == DualTile.RIGHT) onFocusChange(DualTile.LEFT) + true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (focusedTile == DualTile.LEFT) onFocusChange(DualTile.RIGHT) + true + } + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (keyEvent.nativeKeyEvent.repeatCount == 0) scheduleLongPress(focusedTile) + true + } + KeyEvent.KEYCODE_MENU -> { menuOpenedViaLongPress.value = false; onOpenActionMenu(focusedTile); true } + else -> false + } + KeyEventType.KeyUp -> when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (swallowOkUp.value) { + swallowOkUp.value = false + cancelLongPress() + longPress.handled = false + return@onPreviewKeyEvent true + } + val consumedByLongPress = longPress.handled + cancelLongPress() + if (!consumedByLongPress) { + val tile = focusedTile + val tileError = when (tile) { + DualTile.LEFT -> leftTileState is DualTilePlayerState.Error + DualTile.RIGHT -> rightTileState is DualTilePlayerState.Error + } + val hasChannel = if (tile == DualTile.LEFT) primaryChannel != null else rightChannel != null + when { + tileError -> onRetryError(tile) + hasChannel -> onEnterFullscreen(tile) + else -> onOpenChannelPicker(tile) + } + } + longPress.handled = false + true + } + else -> false + } + else -> false + } + } + ) { + Row(modifier = Modifier.fillMaxSize()) { + DualLiveTile( + tile = DualTile.LEFT, + player = primaryPlayer, + channel = primaryChannel, + playerState = leftTileState, + isFocused = focusedTile == DualTile.LEFT, + onRetryError = { onRetryError(DualTile.LEFT) }, + modifier = Modifier.weight(1f) + ) + Box(modifier = Modifier.width(2.dp).fillMaxSize().background(Color.Black)) + DualLiveTile( + tile = DualTile.RIGHT, + player = secondaryPlayer, + channel = rightChannel, + playerState = rightTileState, + isFocused = focusedTile == DualTile.RIGHT, + onAddChannel = { onOpenChannelPicker(DualTile.RIGHT) }, + onRetryError = { onRetryError(DualTile.RIGHT) }, + modifier = Modifier.weight(1f) + ) + } + + val menuTile = actionMenuTile + if (menuTile != null) { + DualLiveActionMenu( + tile = menuTile, + openedViaLongPress = menuOpenedViaLongPress.value, + hasChannel = if (menuTile == DualTile.LEFT) primaryChannel != null else rightChannel != null, + onChangeChannel = { onCloseActionMenu(); onOpenChannelPicker(menuTile) }, + onCloseThisScreen = { onCloseActionMenu(); onCloseTile(menuTile) }, + onRestartStream = { onCloseActionMenu(); onRestartStream(menuTile) }, + onAddChannel = { onCloseActionMenu(); onOpenChannelPicker(menuTile) }, + onExitDualScreen = { onCloseActionMenu(); onExitDualScreen() }, + onDismiss = onCloseActionMenu + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun DualLiveTile( + tile: DualTile, + player: Player?, + channel: ContentItem?, + playerState: DualTilePlayerState, + isFocused: Boolean, + modifier: Modifier = Modifier, + onAddChannel: (() -> Unit)? = null, + onRetryError: (() -> Unit)? = null +) { + val colors = AppTheme.colors + val accentColor = colors.accent + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + ) { + if (player != null && channel != null) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + setShutterBackgroundColor(android.graphics.Color.BLACK) + isFocusable = false + isFocusableInTouchMode = false + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + }, + update = { view -> + if (view.player != player) view.player = player + }, + modifier = Modifier.fillMaxSize(), + onRelease = { view -> view.player = null } + ) + + if (playerState is DualTilePlayerState.Loading || playerState is DualTilePlayerState.Reconnecting) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = accentColor, modifier = Modifier.size(36.dp), strokeWidth = 3.dp) + } + } + + if (playerState is DualTilePlayerState.Error) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)) + .then(if (onRetryError != null) Modifier.clickable { onRetryError() } else Modifier), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Stream failed", + color = Color.White, + fontSize = 14.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + if (!playerState.message.isNullOrBlank()) { + Text( + text = playerState.message, + color = Color.White.copy(alpha = 0.65f), + fontSize = 11.sp, + fontFamily = AppTheme.fontFamily, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Text( + text = "Press OK to retry • Long-press for options", + color = accentColor, + fontSize = 11.sp, + fontFamily = AppTheme.fontFamily, + textAlign = TextAlign.Center + ) + } + } + } + + if (isFocused && playerState !is DualTilePlayerState.Error && playerState !is DualTilePlayerState.Reconnecting) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.45f)) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = channel.title, + color = Color.White, + fontSize = 13.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .then(if (onAddChannel != null) Modifier.clickable { onAddChannel() } else Modifier), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = if (isFocused) 0.18f else 0.10f)) + .border( + 1.dp, + if (isFocused) accentColor.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.25f), + RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "+", + color = if (isFocused) accentColor else Color.White.copy(alpha = 0.6f), + fontSize = 28.sp, + fontWeight = FontWeight.Light + ) + } + Text( + text = "Add channel", + color = Color.White.copy(alpha = if (isFocused) 0.9f else 0.5f), + fontSize = 12.sp, + fontFamily = AppTheme.fontFamily + ) + } + } + } + + // Focused border as a separate child Box so it's isolated from the tile content. + // Only composed when focused — zero cost when not focused. + if (isFocused) { + Box(modifier = Modifier.fillMaxSize().border(3.dp, accentColor)) + } + } +} + +@Composable +private fun DualLiveMenuRow( + label: String, + isSelected: Boolean, + accentColor: Color, + textColor: Color +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(if (isSelected) accentColor.copy(alpha = 0.18f) else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = label, + color = if (isSelected) accentColor else textColor, + fontSize = 14.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + ) + } +} + +@Composable +private fun DualLiveActionMenu( + tile: DualTile, + openedViaLongPress: Boolean, + hasChannel: Boolean, + onChangeChannel: () -> Unit, + onCloseThisScreen: () -> Unit, + onRestartStream: () -> Unit, + onAddChannel: () -> Unit, + onExitDualScreen: () -> Unit, + onDismiss: () -> Unit +) { + val colors = AppTheme.colors + val menuItems: List Unit>> = if (hasChannel) { + listOf( + "Change channel" to onChangeChannel, + "Close this screen" to onCloseThisScreen, + "Restart this stream" to onRestartStream, + "Exit dual screen" to onExitDualScreen + ) + } else { + listOf( + "Add channel" to onAddChannel, + "Exit dual screen" to onExitDualScreen + ) + } + + var selectedIndex by remember { mutableStateOf(0) } + // Suppress first OK selection only when opened via long-OK (the OK key-up must be swallowed). + // Menu/Gear-opened menus have no pending OK key-up so suppress is not needed. + val suppressSelect = remember { object { var value = openedViaLongPress } } + val menuFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + runCatching { menuFocusRequester.requestFocus() } + } + BackHandler(enabled = true) { + onDismiss() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .width(280.dp) + .clip(RoundedCornerShape(16.dp)) + .background(colors.background) + .border(1.dp, colors.border, RoundedCornerShape(16.dp)) + .padding(vertical = 12.dp) + .focusRequester(menuFocusRequester) + .focusable() + .onPreviewKeyEvent { keyEvent -> + when (keyEvent.type) { + KeyEventType.KeyUp -> when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + suppressSelect.value = false + true + } + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_ESCAPE -> true + else -> false + } + KeyEventType.KeyDown -> when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + selectedIndex = (selectedIndex - 1).coerceAtLeast(0) + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + selectedIndex = (selectedIndex + 1).coerceAtMost(menuItems.lastIndex) + true + } + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (suppressSelect.value || keyEvent.nativeKeyEvent.repeatCount > 0) { + return@onPreviewKeyEvent true + } + menuItems[selectedIndex].second.invoke() + true + } + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_ESCAPE -> { onDismiss(); true } + else -> false + } + else -> false + } + } + ) { + Text( + text = if (tile == DualTile.LEFT) "Left tile" else "Right tile", + color = colors.textTertiary, + fontSize = 11.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + Spacer(Modifier.height(4.dp)) + menuItems.forEachIndexed { index, (label, _) -> + DualLiveMenuRow( + label = label, + isSelected = index == selectedIndex, + accentColor = colors.accent, + textColor = colors.textPrimary + ) + } + } + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/DualLiveSession.kt b/app/src/main/java/com/example/xtreamplayer/DualLiveSession.kt new file mode 100644 index 0000000..3eb328e --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/DualLiveSession.kt @@ -0,0 +1,13 @@ +package com.example.xtreamplayer + +enum class DualTile { LEFT, RIGHT } + +fun DualTile.opposite(): DualTile = if (this == DualTile.LEFT) DualTile.RIGHT else DualTile.LEFT + +sealed interface DualTilePlayerState { + data object Idle : DualTilePlayerState + data object Loading : DualTilePlayerState + data object Reconnecting : DualTilePlayerState // silent auto-retry, shows spinner not error + data object Ready : DualTilePlayerState + data class Error(val message: String?) : DualTilePlayerState +} diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 319f2d8..0b43f90 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -2396,6 +2396,18 @@ fun RootScreen( onSubtitleStateChanged = { state -> activePlaybackSubtitleState = state }, + dualLiveEnabled = settings.dualLiveEnabled, + loadLiveStreamUri = loadLiveStreamUri@{ item -> + val config = authState.activeConfig ?: return@loadLiveStreamUri null + Uri.parse( + StreamUrlBuilder.buildUrl( + config = config, + type = item.contentType, + streamId = item.streamId, + extension = item.containerExtension + ) + ) + }, loadLiveNowNext = loadLiveNowNext@{ item -> val config = authState.activeConfig ?: return@loadLiveNowNext Result.success(null) if (item.contentType != ContentType.LIVE) { diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUiPlayer.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUiPlayer.kt index 9b84f49..d2875e5 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUiPlayer.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUiPlayer.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -70,6 +71,9 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -108,6 +112,7 @@ import com.example.xtreamplayer.ui.SubtitleTrackDialog import com.example.xtreamplayer.ui.VideoResolutionDialog import com.example.xtreamplayer.ui.theme.AppTheme import com.example.xtreamplayer.settings.SubtitleAppearanceSettings +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -131,6 +136,16 @@ private const val LIVE_GUIDE_MAX_ROWS_VISIBLE = 10 private const val NERD_STATS_REFRESH_INTERVAL_MS = 500L private const val SUBTITLE_OFFSET_PREVIEW_DEBOUNCE_MS = 700L private const val NEXT_EPISODE_COUNTDOWN_SECONDS = 15 +private const val DUAL_MAX_RECONNECT_ATTEMPTS = 10 + +private fun dualReconnectDelayMs(attempt: Int): Long = when (attempt) { + 0 -> 1L + 1 -> 500L + 2 -> 1_000L + 3 -> 2_000L + 4 -> 4_000L + else -> 8_000L +} private val LANGUAGE_PREFIX_REGEX = Regex("^\\s*[A-Z]{2,3}\\s*-\\s*") private val BRACKET_TEXT_REGEX = Regex("\\[[^\\]]*\\]") private val PAREN_TEXT_REGEX = Regex("\\([^\\)]*\\)") @@ -581,6 +596,8 @@ internal fun PlayerOverlay( onLiveChannelSwitch: (Int) -> Boolean, onLiveGuideChannelSelect: (ContentItem, List) -> Unit, onSubtitleStateChanged: (PlaybackSubtitleState?) -> Unit, + dualLiveEnabled: Boolean, + loadLiveStreamUri: suspend (ContentItem) -> Uri?, loadLiveNowNext: suspend (ContentItem) -> Result, loadLiveCategories: suspend () -> Result>, loadLiveCategoryChannels: suspend (CategoryItem) -> Result>, @@ -738,6 +755,24 @@ internal fun PlayerOverlay( } } + // --- Dual screen state --- + var isDualScreenActive by remember { mutableStateOf(false) } + var dualScreenFullscreenTile by remember { mutableStateOf(null) } + var dualScreenLeftClosed by remember { mutableStateOf(false) } + var dualScreenRightChannel by remember { mutableStateOf(null) } + var dualScreenRightChannels by remember { mutableStateOf>(emptyList()) } + var dualScreenFocusedTile by remember { mutableStateOf(DualTile.LEFT) } + var dualLivePickerTarget by remember { mutableStateOf(null) } + // Action menu tile lives here so the parent BackHandler can dismiss it before exiting dual screen. + var dualActionMenuTile by remember { mutableStateOf(null) } + var secondaryExoPlayer by remember { mutableStateOf(null) } + var rightTileState by remember { mutableStateOf(DualTilePlayerState.Idle) } + var dualRightRetryCount by remember { mutableStateOf(0) } + // Plain ref — only read/written in the reconnect handler, never drives recomposition. + val dualLeftRetries = remember { object { var count = 0 } } + var leftTileState by remember { mutableStateOf(DualTilePlayerState.Ready) } + var wasDualScreenActive by remember { mutableStateOf(false) } + val closeLiveGuide: () -> Unit = { showLiveGuide = false liveGuideErrorMessage = null @@ -748,11 +783,13 @@ internal fun PlayerOverlay( liveGuideSearchResultIndex = 0 liveGuideSearchReturnIndex = 0 hideLiveGuideSearchKeyboard() - playerView?.let { view -> - view.post { - view.useController = true - view.showController() - view.focusPlayPause() + if (!isDualScreenActive) { + playerView?.let { view -> + view.post { + view.useController = true + view.showController() + view.focusPlayPause() + } } } } @@ -896,9 +933,24 @@ internal fun PlayerOverlay( liveGuideSearchResultIndex = 0 liveGuideSearchReturnIndex = 0 hideLiveGuideSearchKeyboard() + val guideTarget = + dualLivePickerTarget + ?: if (isDualScreenActive) dualScreenFocusedTile else null + val guideReferenceChannel = + if (guideTarget == DualTile.RIGHT) { + dualScreenRightChannel + } else { + activePlaybackItem + } + val guideQueueItems = + if (guideTarget == DualTile.RIGHT && dualScreenRightChannels.isNotEmpty()) { + dualScreenRightChannels + } else { + liveQueueItems + } val queueCategoryId = - normalizeCategoryId(activePlaybackItem?.categoryId) - ?: liveQueueItems + normalizeCategoryId(guideReferenceChannel?.categoryId) + ?: guideQueueItems .mapNotNull { normalizeCategoryId(it.categoryId) } .groupingBy { it } .eachCount() @@ -906,8 +958,8 @@ internal fun PlayerOverlay( ?.key ?: inferCategoryIdFromChannelTitles( categories = liveGuideCategories, - activeChannel = activePlaybackItem, - channels = liveQueueItems + activeChannel = guideReferenceChannel, + channels = guideQueueItems ) ?: normalizeCategoryId(liveGuideParentCategoryId) ?: normalizeCategoryId(liveGuidePreferredCategoryId) @@ -920,20 +972,23 @@ internal fun PlayerOverlay( liveGuideSelectedCategory = liveGuideCategories[preferredIndex] } } - if (liveQueueItems.size > 1) { - liveGuideChannels = liveQueueItems + if (guideQueueItems.size > 1) { + liveGuideChannels = guideQueueItems liveGuideLevel = LiveGuideLevel.CHANNELS liveGuideChannelIndex = - liveQueueItems.indexOfFirst { - it.id == activePlaybackItem?.id + guideQueueItems.indexOfFirst { + guideReferenceChannel != null && + liveGuideChannelCacheKey(it) == liveGuideChannelCacheKey(guideReferenceChannel) }.takeIf { it >= 0 } ?: 0 } else { liveGuideLevel = LiveGuideLevel.CATEGORIES } showLiveGuide = true playerView?.hideController() - playerView?.resetControllerFocus() - playerView?.requestFocus() + if (!isDualScreenActive) { + playerView?.resetControllerFocus() + playerView?.requestFocus() + } if (liveGuideCategories.isEmpty()) { refreshLiveCategories() } else { @@ -1093,6 +1148,31 @@ internal fun PlayerOverlay( } } + fun selectLiveGuideChannel(channel: ContentItem, siblingChannels: List) { + val pickerTarget = + if (isDualScreenActive) { + dualLivePickerTarget ?: dualScreenFocusedTile + } else { + null + } + if (isDualScreenActive && pickerTarget != null) { + dualLivePickerTarget = null + if (pickerTarget == DualTile.RIGHT) { + dualScreenRightChannel = channel + dualScreenRightChannels = siblingChannels + dualScreenFocusedTile = DualTile.RIGHT + closeLiveGuide() + } else { + dualScreenLeftClosed = false + dualScreenFocusedTile = DualTile.LEFT + closeLiveGuide() + onLiveGuideChannelSelect(channel, siblingChannels) + } + return + } + onLiveGuideChannelSelect(channel, siblingChannels) + } + fun activateLiveGuideSelection() { if (liveGuideLoading) return playerView?.hideController() @@ -1122,7 +1202,7 @@ internal fun PlayerOverlay( categoryId?.let { liveGuideSearchChannelsByCategoryId[it] } ?.takeIf { it.isNotEmpty() } ?: liveGuideChannels.ifEmpty { listOf(row.channel) } - onLiveGuideChannelSelect(row.channel, siblingChannels) + selectLiveGuideChannel(row.channel, siblingChannels) } } return @@ -1132,15 +1212,16 @@ internal fun PlayerOverlay( return } val channel = liveGuideChannels.getOrNull(liveGuideChannelIndex) ?: return - if (channel.id == activePlaybackItem?.id) { + if (!isDualScreenActive && channel.id == activePlaybackItem?.id) { closeLiveGuide() return } - onLiveGuideChannelSelect(channel, liveGuideChannels) + selectLiveGuideChannel(channel, liveGuideChannels) } LaunchedEffect(currentContentType) { if (currentContentType != ContentType.LIVE) { + dualLivePickerTarget = null showLiveGuide = false } } @@ -1830,15 +1911,76 @@ internal fun PlayerOverlay( } } + fun exitDualScreen() { + isDualScreenActive = false + dualScreenFullscreenTile = null + dualScreenLeftClosed = false + dualLivePickerTarget = null + dualActionMenuTile = null + dualScreenRightChannel = null + dualScreenRightChannels = emptyList() + rightTileState = DualTilePlayerState.Idle + secondaryExoPlayer?.release() + secondaryExoPlayer = null + player.volume = 1f + player.playWhenReady = true + } + + fun exitDualScreenToFocusedChannel() { + val rightChannel = dualScreenRightChannel + val shouldPromoteRight = + rightChannel != null && + (dualScreenFocusedTile == DualTile.RIGHT || dualScreenLeftClosed) + if (shouldPromoteRight) { + val channels = + dualScreenRightChannels + .takeIf { it.any { channel -> liveGuideChannelCacheKey(channel) == liveGuideChannelCacheKey(rightChannel) } } + ?: activePlaybackItems + .filter { it.contentType == ContentType.LIVE } + .takeIf { it.any { channel -> liveGuideChannelCacheKey(channel) == liveGuideChannelCacheKey(rightChannel) } } + ?: listOf(rightChannel) + onLiveGuideChannelSelect(rightChannel, channels) + } + exitDualScreen() + } + + fun closeDualScreenTile(tile: DualTile) { + if (tile == DualTile.RIGHT) { + dualScreenRightChannel = null + dualScreenRightChannels = emptyList() + secondaryExoPlayer?.release() + secondaryExoPlayer = null + rightTileState = DualTilePlayerState.Idle + dualScreenFocusedTile = DualTile.RIGHT + } else { + dualScreenLeftClosed = true + player.volume = 0f + player.pause() + dualScreenFocusedTile = DualTile.LEFT + } + } + BackHandler(enabled = true) { when { showNextEpisodeOverlay -> dismissNextEpisodePrompt() - showLiveGuide -> closeLiveGuide() + // Action menu must be dismissed before anything else in dual-screen mode. + // Keeping this state in the parent prevents a race where the action menu's + // own BackHandler hasn't been composed yet but Back is already dispatched. + dualActionMenuTile != null -> dualActionMenuTile = null + showLiveGuide -> { dualLivePickerTarget = null; closeLiveGuide() } showSubtitleTimingDialog -> showSubtitleTimingDialog = false showSubtitleTrackDialog -> showSubtitleTrackDialog = false showPlaybackSettingsDialog -> showPlaybackSettingsDialog = false showPlaybackSpeedDialog -> showPlaybackSpeedDialog = false showResolutionDialog -> showResolutionDialog = false + dualScreenFullscreenTile != null -> { + if (playerView?.isControllerFullyVisible == true) { + playerView?.hideController() + } else { + dualScreenFullscreenTile = null + } + } + isDualScreenActive -> exitDualScreenToFocusedChannel() else -> { if (playerView?.isControllerFullyVisible == true) { playerView?.hideController() @@ -1920,6 +2062,248 @@ internal fun PlayerOverlay( // live-only UI leaking into VOD when item metadata lags during transitions. val isLivePlayback = currentContentType == ContentType.LIVE + val effectivePlayer: Player = if (dualScreenFullscreenTile == DualTile.RIGHT && secondaryExoPlayer != null) secondaryExoPlayer!! else player + + DisposableEffect(Unit) { + onDispose { + secondaryExoPlayer?.release() + secondaryExoPlayer = null + } + } + + DisposableEffect(isDualScreenActive) { + if (isDualScreenActive) { + player.repeatMode = Player.REPEAT_MODE_ONE + } else { + player.repeatMode = Player.REPEAT_MODE_OFF + } + onDispose { + player.repeatMode = Player.REPEAT_MODE_OFF + } + } + + // Pause the secondary player when the app goes to background. Unlike the primary player, + // the secondary has no media session or audio focus so it must be explicitly lifecycle-managed. + val dualLifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(dualLifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> secondaryExoPlayer?.pause() + Lifecycle.Event.ON_START -> { + if (isDualScreenActive) secondaryExoPlayer?.playWhenReady = true + } + else -> Unit + } + } + dualLifecycleOwner.lifecycle.addObserver(observer) + onDispose { dualLifecycleOwner.lifecycle.removeObserver(observer) } + } + + DisposableEffect(secondaryExoPlayer) { + val secPlayer = secondaryExoPlayer + if (secPlayer == null) return@DisposableEffect onDispose {} + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> rightTileState = DualTilePlayerState.Loading + Player.STATE_READY -> { + dualRightRetryCount = 0 // stream recovered — reset so next drop gets fresh attempts + rightTileState = DualTilePlayerState.Ready + } + // STATE_ENDED means provider dropped the stream — go to Reconnecting so + // the LaunchedEffect below silently restarts without showing "Stream failed". + Player.STATE_ENDED -> rightTileState = if (dualScreenRightChannel != null) DualTilePlayerState.Reconnecting else DualTilePlayerState.Idle + else -> rightTileState = DualTilePlayerState.Idle + } + } + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + // Also use Reconnecting for errors — the LaunchedEffect will surface Error + // only if the URL lookup fails, so transient failures stay silent. + if (dualScreenRightChannel != null) { + rightTileState = DualTilePlayerState.Reconnecting + } else { + rightTileState = DualTilePlayerState.Error(error.message) + } + } + } + secPlayer.addListener(listener) + onDispose { secPlayer.removeListener(listener) } + } + + LaunchedEffect(dualScreenRightChannel) { + dualRightRetryCount = 0 + val channel = dualScreenRightChannel + if (channel == null) { + rightTileState = DualTilePlayerState.Idle + secondaryExoPlayer?.release() + secondaryExoPlayer = null + return@LaunchedEffect + } + rightTileState = DualTilePlayerState.Loading + val uri = loadLiveStreamUri(channel) + if (uri == null) { + rightTileState = DualTilePlayerState.Error("Failed to build stream URL") + return@LaunchedEffect + } + var secPlayer = secondaryExoPlayer + if (secPlayer == null) { + secPlayer = playbackEngine.createSecondaryLivePlayer() + secondaryExoPlayer = secPlayer + } + secPlayer.setMediaItem(androidx.media3.common.MediaItem.fromUri(uri)) + secPlayer.prepare() + secPlayer.playWhenReady = true + if (dualScreenFocusedTile == DualTile.RIGHT) { + player.volume = 0f + secPlayer.volume = 1f + } else { + player.volume = 1f + secPlayer.volume = 0f + } + } + + // Silent auto-reconnect for the right (secondary) tile. + // Triggered by Reconnecting state — set by the player listener on STATE_ENDED or error. + // Uses exponential backoff and gives up after DUAL_MAX_RECONNECT_ATTEMPTS, at which point + // the tile shows an error the user can dismiss. Only surfaces Error if URL lookup fails or + // all retries are exhausted; transient failures stay silent. + LaunchedEffect(rightTileState, dualScreenRightChannel, isDualScreenActive) { + val channel = dualScreenRightChannel ?: return@LaunchedEffect + if (!isDualScreenActive) return@LaunchedEffect + if (rightTileState !is DualTilePlayerState.Reconnecting) return@LaunchedEffect + + val attempt = dualRightRetryCount + if (attempt >= DUAL_MAX_RECONNECT_ATTEMPTS) { + rightTileState = DualTilePlayerState.Error("Stream unavailable after $DUAL_MAX_RECONNECT_ATTEMPTS retries") + return@LaunchedEffect + } + dualRightRetryCount = attempt + 1 + + delay(dualReconnectDelayMs(attempt)) + + if (rightTileState !is DualTilePlayerState.Reconnecting) return@LaunchedEffect + if (dualScreenRightChannel != channel) return@LaunchedEffect + + val uri = loadLiveStreamUri(channel) + if (uri == null) { + rightTileState = DualTilePlayerState.Error("Failed to build stream URL") + return@LaunchedEffect + } + var secPlayer = secondaryExoPlayer + if (secPlayer == null) { + secPlayer = playbackEngine.createSecondaryLivePlayer() + secondaryExoPlayer = secPlayer + } + secPlayer.setMediaItem(androidx.media3.common.MediaItem.fromUri(uri)) + secPlayer.prepare() + secPlayer.playWhenReady = true + // Restore volume based on current focus (may have changed while reconnecting). + if (dualScreenFocusedTile == DualTile.RIGHT) { + player.volume = 0f + secPlayer.volume = 1f + } else { + secPlayer.volume = 0f + } + } + + // Silent auto-reconnect for the left (primary) tile in dual-screen mode. + // Uses Handler-based backoff (same schedule as right tile) so a bad URL doesn't spin + // at full speed. After DUAL_MAX_RECONNECT_ATTEMPTS the tile goes silent — the user can + // restart manually via the tile action menu. + DisposableEffect(isDualScreenActive, activePlaybackItem?.id) { + dualLeftRetries.count = 0 + leftTileState = DualTilePlayerState.Ready + if (!isDualScreenActive) return@DisposableEffect onDispose {} + if (activePlaybackItem == null) return@DisposableEffect onDispose {} + val reconnectHandler = android.os.Handler(android.os.Looper.getMainLooper()) + val listener = object : Player.Listener { + private fun scheduleReconnect() { + val attempt = dualLeftRetries.count + if (attempt >= DUAL_MAX_RECONNECT_ATTEMPTS) { + leftTileState = DualTilePlayerState.Error("Stream unavailable after $DUAL_MAX_RECONNECT_ATTEMPTS retries") + return + } + dualLeftRetries.count = attempt + 1 + reconnectHandler.postDelayed({ + if (!isDualScreenActive || currentContentType != ContentType.LIVE) return@postDelayed + player.prepare() + player.playWhenReady = true + }, dualReconnectDelayMs(attempt)) + } + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + dualLeftRetries.count = 0 // stream recovered — reset so next drop gets fresh attempts + leftTileState = DualTilePlayerState.Ready + } + Player.STATE_ENDED -> { + if (!isDualScreenActive) return + if (currentContentType != ContentType.LIVE) return + scheduleReconnect() + } + else -> {} + } + } + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + if (!isDualScreenActive) return + if (currentContentType != ContentType.LIVE) return + scheduleReconnect() + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + reconnectHandler.removeCallbacksAndMessages(null) + } + } + + LaunchedEffect( + isDualScreenActive, + dualScreenFocusedTile, + dualScreenFullscreenTile, + dualScreenLeftClosed, + dualScreenRightChannel, + secondaryExoPlayer + ) { + if (!isDualScreenActive) { + player.volume = 1f + return@LaunchedEffect + } + val fullscreenTile = dualScreenFullscreenTile + if (fullscreenTile != null) { + if (fullscreenTile == DualTile.LEFT) { + player.volume = 1f + secondaryExoPlayer?.volume = 0f + player.playWhenReady = true + } else { + player.volume = 0f + secondaryExoPlayer?.volume = 1f + secondaryExoPlayer?.playWhenReady = true + } + return@LaunchedEffect + } + delay(300) + val leftPlayable = !dualScreenLeftClosed + val rightPlayable = dualScreenRightChannel != null && secondaryExoPlayer != null + val audibleTile = + when { + dualScreenFocusedTile == DualTile.LEFT && leftPlayable -> DualTile.LEFT + dualScreenFocusedTile == DualTile.RIGHT && rightPlayable -> DualTile.RIGHT + leftPlayable -> DualTile.LEFT + rightPlayable -> DualTile.RIGHT + else -> DualTile.LEFT + } + if (audibleTile == DualTile.LEFT) { + player.volume = 1f + secondaryExoPlayer?.volume = 0f + player.playWhenReady = true + } else { + player.volume = 0f + secondaryExoPlayer?.volume = 1f + secondaryExoPlayer?.playWhenReady = true + } + } + val bindMutablePlayerCallbacks: (XtreamPlayerView) -> Unit = { view -> view.resizeMode = resizeMode.resizeMode view.forcedAspectRatio = resizeMode.forcedAspectRatio @@ -1937,6 +2321,16 @@ internal fun PlayerOverlay( view.onAudioBoostClick = { showAudioBoostDialog = true } view.onSettingsClick = { showPlaybackSettingsDialog = true } view.isLiveContent = isLivePlayback + view.isDualLiveEnabled = dualLiveEnabled + view.onDualScreenClick = if (isLivePlayback && !isPlayerModalOpen && !showLiveGuide) { + { + isDualScreenActive = true + dualScreenLeftClosed = false + dualScreenFocusedTile = DualTile.LEFT + } + } else { + null + } view.fastSeekEnabled = !isLivePlayback view.defaultControllerTimeoutMs = 3000 view.onToggleControls = { @@ -1952,21 +2346,27 @@ internal fun PlayerOverlay( } } view.onChannelUp = { - if (!isPlayerModalOpen && isLivePlayback) { + // Disable channel switching when fullscreen-from-right is active — the right tile + // uses a separate player/queue and onLiveChannelSwitch operates on the left queue. + if (!isPlayerModalOpen && isLivePlayback && !(isDualScreenActive && dualScreenFullscreenTile == DualTile.RIGHT)) { onLiveChannelSwitch(1) } else { false } } view.onChannelDown = { - if (!isPlayerModalOpen && isLivePlayback) { + if (!isPlayerModalOpen && isLivePlayback && !(isDualScreenActive && dualScreenFullscreenTile == DualTile.RIGHT)) { onLiveChannelSwitch(-1) } else { false } } view.onOpenLiveGuide = { - if (!isPlayerModalOpen && isLivePlayback) { + if (isDualScreenActive && dualScreenFullscreenTile != null && !isPlayerModalOpen && isLivePlayback) { + dualLivePickerTarget = dualScreenFullscreenTile + showLiveGuidePanel() + true + } else if (!isDualScreenActive && !isPlayerModalOpen && isLivePlayback) { showLiveGuidePanel() true } else { @@ -1987,11 +2387,39 @@ internal fun PlayerOverlay( } view.onLiveGuideSearchKey = { event -> handleLiveGuideSearchKey(event) } view.onLiveGuideDismiss = { + dualLivePickerTarget = null closeLiveGuide() true } } + LaunchedEffect(isDualScreenActive, dualScreenFullscreenTile, showLiveGuide) { + val returningFromDualScreen = wasDualScreenActive && !isDualScreenActive + val shouldRestoreFullscreenControls = + !showLiveGuide && + ( + (isDualScreenActive && dualScreenFullscreenTile != null) || + returningFromDualScreen + ) + wasDualScreenActive = isDualScreenActive + if (shouldRestoreFullscreenControls) { + playerView?.post { + val view = playerView ?: return@post + val timeoutMs = view.defaultControllerTimeoutMs + view.isFocusable = true + view.isFocusableInTouchMode = true + view.useController = true + view.hideController() + view.setControllerShowTimeoutMs(timeoutMs) + view.requestFocus() + view.postDelayed({ + view.setControllerShowTimeoutMs(timeoutMs) + view.showController() + }, 80L) + } + } + } + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { AndroidView( modifier = Modifier.fillMaxSize(), @@ -2032,6 +2460,7 @@ internal fun PlayerOverlay( return@setOnKeyListener true } if (showLiveGuide) { + dualLivePickerTarget = null closeLiveGuide() return@setOnKeyListener true } @@ -2041,7 +2470,12 @@ internal fun PlayerOverlay( } val dismissed = dismissSettingsWindowIfShowing() if (!dismissed) { - onExit() + // Fullscreen-from-split: Back returns to split, not exit. + if (dualScreenFullscreenTile != null) { + dualScreenFullscreenTile = null + } else { + onExit() + } } return@setOnKeyListener true } @@ -2054,9 +2488,12 @@ internal fun PlayerOverlay( } }, update = { view -> - view.player = player - view.useController = !showLiveGuide - if (showLiveGuide) { + val dualSplitActive = isDualScreenActive && dualScreenFullscreenTile == null + view.player = if (dualSplitActive) null else effectivePlayer + view.useController = !showLiveGuide && !dualSplitActive + view.isFocusable = !dualSplitActive + view.isFocusableInTouchMode = !dualSplitActive + if (showLiveGuide || dualSplitActive) { view.hideController() } bindMutablePlayerCallbacks(view) @@ -2065,234 +2502,570 @@ internal fun PlayerOverlay( } } ) - // Next Episode Overlay - if (showNextEpisodeOverlay && nextEpisodeTitle != null) { - NextEpisodeOverlay( - nextEpisodeTitle = nextEpisodeTitle, - countdownSeconds = NEXT_EPISODE_COUNTDOWN_SECONDS, - remainingSeconds = countdownRemaining, - controlsVisible = controlsVisible, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - - if (currentContentType != ContentType.LIVE && showVodStartupIndicator) { - Box( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(14.dp)) - .background(Color.Black.copy(alpha = 0.55f)) - .border(1.dp, AppTheme.colors.borderStrong, RoundedCornerShape(14.dp)) - .padding(horizontal = 20.dp, vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + PlayerBoxOverlays( + showNextEpisodeOverlay = showNextEpisodeOverlay, + nextEpisodeTitle = nextEpisodeTitle, + countdownRemaining = countdownRemaining, + controlsVisible = controlsVisible, + currentContentType = currentContentType, + showVodStartupIndicator = showVodStartupIndicator, + showNerdStats = showNerdStats, + nerdStats = nerdStats, + showSubtitleTimingDialog = showSubtitleTimingDialog, + activeSubtitle = activeSubtitle, + subtitleOffsetMs = subtitleOffsetMs, + subtitleTimingSessionStartOffsetMs = subtitleTimingSessionStartOffsetMs, + lastAppliedOffsetMs = lastAppliedOffsetMs, + isDualScreenActive = isDualScreenActive, + dualScreenFullscreenTile = dualScreenFullscreenTile, + primaryPlayer = player, + secondaryPlayer = secondaryExoPlayer, + primaryChannel = activePlaybackItem.takeUnless { dualScreenLeftClosed }, + rightChannel = dualScreenRightChannel, + focusedTile = dualScreenFocusedTile, + leftTileState = leftTileState, + rightTileState = rightTileState, + actionMenuTile = dualActionMenuTile, + isGuideOpen = showLiveGuide, + activeChannelId = if (isDualScreenActive && (dualLivePickerTarget ?: dualScreenFocusedTile) == DualTile.RIGHT) { + dualScreenRightChannel?.id + } else { + activePlaybackItem?.id + }, + liveGuideLevel = liveGuideLevel, + liveGuideCategories = liveGuideCategories, + liveGuideChannels = liveGuideChannels, + liveGuideCategoryIndex = liveGuideCategoryIndex, + liveGuideChannelIndex = liveGuideChannelIndex, + liveGuideLoading = liveGuideLoading, + liveGuideErrorMessage = liveGuideErrorMessage, + liveGuideSelectedCategoryName = liveGuideSelectedCategory?.name, + liveGuideSearchQuery = liveGuideSearchQuery, + liveGuideSearchFocused = liveGuideSearchFocused, + liveGuideSearchEditing = liveGuideSearchEditing, + liveGuideSearchEditRequestNonce = liveGuideSearchEditRequestNonce, + liveGuideSearchChannelsLoading = liveGuideSearchChannelsLoading, + liveGuideSearchActive = liveGuideSearchActive, + liveGuideSearchRows = liveGuideSearchRows, + liveGuideSearchResultIndex = liveGuideSearchResultIndex, + selectedLiveGuideTickerText = selectedLiveGuideTickerText, + selectedLiveGuideTickerLoading = selectedLiveGuideTickerLoading, + liveGuideReturnCategoryAtTop = liveGuideReturnCategoryAtTop, + onDismissNextEpisodePrompt = dismissNextEpisodePrompt, + onSubtitleTimingOffsetChange = { newOffset -> + subtitleOffsetMs = newOffset + scheduleOffsetApply(newOffset) + }, + onSubtitleTimingCancel = { + offsetApplyJob?.cancel() + offsetApplyJob = null + val restoreOffset = subtitleTimingSessionStartOffsetMs + subtitleOffsetMs = restoreOffset + showSubtitleTimingDialog = false + if (restoreOffset != lastAppliedOffsetMs) { + applyOffsetNow(restoreOffset) + } + }, + onSubtitleTimingDismiss = { + offsetApplyJob?.cancel() + offsetApplyJob = null + showSubtitleTimingDialog = false + if (subtitleOffsetMs != lastAppliedOffsetMs) { + applyOffsetNow(subtitleOffsetMs) + } + }, + onOpenActionMenu = { tile -> dualActionMenuTile = tile }, + onCloseActionMenu = { dualActionMenuTile = null }, + onFocusChange = { tile -> dualScreenFocusedTile = tile }, + onEnterFullscreen = { tile -> + if ((tile == DualTile.LEFT && !dualScreenLeftClosed) || + (tile == DualTile.RIGHT && secondaryExoPlayer != null) ) { - CircularProgressIndicator( - modifier = Modifier.width(28.dp).height(28.dp), - color = AppTheme.colors.accent, - strokeWidth = 3.dp - ) - Text( - text = "Loading video...", - color = Color.White, - fontSize = 15.sp, - fontFamily = AppTheme.fontFamily, - fontWeight = FontWeight.Medium - ) + dualScreenFullscreenTile = tile + if (tile == DualTile.LEFT) { + player.volume = 1f + secondaryExoPlayer?.volume = 0f + player.playWhenReady = true + } else { + player.volume = 0f + secondaryExoPlayer?.volume = 1f + secondaryExoPlayer?.playWhenReady = true + } } - } - } - - if (showNerdStats) { - PlayerNerdStatsPanel( - stats = nerdStats, - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 12.dp, top = 64.dp) - ) - } - - // Subtitle offset adjustment overlay with modal backdrop - if (showSubtitleTimingDialog && activeSubtitle != null && isOffsetSupported(activeSubtitle?.fileName)) { - SubtitleOffsetDialog( - offsetMs = subtitleOffsetMs, - onOffsetChange = { newOffset -> - if (newOffset == subtitleOffsetMs) return@SubtitleOffsetDialog - subtitleOffsetMs = newOffset - scheduleOffsetApply(newOffset) - }, - onCancel = { - offsetApplyJob?.cancel() - offsetApplyJob = null - val restoreOffset = subtitleTimingSessionStartOffsetMs - subtitleOffsetMs = restoreOffset - showSubtitleTimingDialog = false - if (restoreOffset != lastAppliedOffsetMs) { - applyOffsetNow(restoreOffset) + }, + onOpenChannelPicker = { tile -> + dualScreenFocusedTile = tile + dualLivePickerTarget = tile + showLiveGuidePanel() + }, + onCloseTile = { tile -> closeDualScreenTile(tile) }, + onRestartStream = { tile -> + if (tile == DualTile.LEFT) { + dualLeftRetries.count = 0 + leftTileState = DualTilePlayerState.Ready + val p = player as? ExoPlayer + if (p != null) { + p.seekToDefaultPosition() + p.prepare() + p.playWhenReady = true } - }, - onDismiss = { - offsetApplyJob?.cancel() - offsetApplyJob = null - showSubtitleTimingDialog = false - if (subtitleOffsetMs != lastAppliedOffsetMs) { - applyOffsetNow(subtitleOffsetMs) + } else { + dualRightRetryCount = 0 + val secPlayer = secondaryExoPlayer + val channel = dualScreenRightChannel + if (secPlayer != null && channel != null) { + subtitleCoroutineScope.launch { + rightTileState = DualTilePlayerState.Loading + val uri = loadLiveStreamUri(channel) + if (uri != null) { + secPlayer.setMediaItem(androidx.media3.common.MediaItem.fromUri(uri)) + secPlayer.prepare() + secPlayer.playWhenReady = true + } else { + rightTileState = DualTilePlayerState.Error("Failed to build stream URL") + } + } } } - ) - } - - if (showLiveGuide && currentContentType == ContentType.LIVE) { - LiveGuidePanel( - level = liveGuideLevel, - categories = liveGuideCategories, - channels = liveGuideChannels, - selectedCategoryIndex = liveGuideCategoryIndex, - selectedChannelIndex = liveGuideChannelIndex, - activeChannelId = activePlaybackItem?.id, - isLoading = liveGuideLoading, - errorMessage = liveGuideErrorMessage, - selectedCategoryName = liveGuideSelectedCategory?.name, - searchQuery = liveGuideSearchQuery, - searchFocused = liveGuideSearchFocused, - searchEditing = liveGuideSearchEditing, - searchEditRequestNonce = liveGuideSearchEditRequestNonce, - searchLoading = liveGuideSearchChannelsLoading, - searchActive = liveGuideSearchActive, - searchRows = liveGuideSearchRows, - selectedSearchIndex = liveGuideSearchResultIndex, - onSearchQueryChange = { query -> updateLiveGuideSearchQuery(query) }, - onSearchMoveUp = { - moveLiveGuideSelection(-1) - playerView?.requestFocus() - }, - onSearchMoveDown = { - moveLiveGuideSelection(1) - playerView?.requestFocus() - }, - onSearchStopEditing = { - liveGuideSearchEditing = false - hideLiveGuideSearchKeyboard() - playerView?.requestFocus() - }, - selectedChannelTickerText = selectedLiveGuideTickerText, - selectedChannelTickerLoading = selectedLiveGuideTickerLoading, - alignCategorySelectionToTop = liveGuideReturnCategoryAtTop, - onCategorySelectionAligned = { liveGuideReturnCategoryAtTop = false }, - categoryThumbnailProvider = { category -> - liveGuideCategoryThumbnails[liveGuideCategoryUiKey(category)] - }, - onCategoryThumbnailNeeded = { category -> - ensureLiveGuideCategoryThumbnail(category) + }, + onRetryError = { tile -> + if (tile == DualTile.LEFT) { + dualLeftRetries.count = 0 + leftTileState = DualTilePlayerState.Ready + player.prepare() + player.playWhenReady = true + } else { + val secPlayer = secondaryExoPlayer + val channel = dualScreenRightChannel + if (secPlayer != null && channel != null) { + subtitleCoroutineScope.launch { + dualRightRetryCount = 0 + rightTileState = DualTilePlayerState.Loading + val uri = loadLiveStreamUri(channel) + if (uri != null) { + secPlayer.setMediaItem(androidx.media3.common.MediaItem.fromUri(uri)) + secPlayer.prepare() + secPlayer.playWhenReady = true + } else { + rightTileState = DualTilePlayerState.Error("Failed to build stream URL") + } + } } - ) + } + }, + onExitDualScreen = { exitDualScreen() }, + onGuideMove = { delta -> moveLiveGuideSelection(delta) }, + onGuideSelect = { activateLiveGuideSelection() }, + onGuideBack = { handleLiveGuideLeft() }, + onGuideDismiss = { + dualLivePickerTarget = null + closeLiveGuide() + }, + onGuideSearchKey = { event -> handleLiveGuideSearchKey(event) }, + onSearchQueryChange = { query -> updateLiveGuideSearchQuery(query) }, + onSearchMoveUp = { + moveLiveGuideSelection(-1) + playerView?.requestFocus() + }, + onSearchMoveDown = { + moveLiveGuideSelection(1) + playerView?.requestFocus() + }, + onSearchStopEditing = { + liveGuideSearchEditing = false + hideLiveGuideSearchKeyboard() + playerView?.requestFocus() + }, + onCategorySelectionAligned = { liveGuideReturnCategoryAtTop = false }, + categoryThumbnailProvider = { category -> + liveGuideCategoryThumbnails[liveGuideCategoryUiKey(category)] + }, + onCategoryThumbnailNeeded = { category -> + ensureLiveGuideCategoryThumbnail(category) + }, + ) + } + + + PlayerDialogsSection( + showSubtitleDialog = showSubtitleDialog, + showSubtitleOptionsDialog = showSubtitleOptionsDialog, + showSubtitleTrackDialog = showSubtitleTrackDialog, + showAudioTrackDialog = showAudioTrackDialog, + showAudioBoostDialog = showAudioBoostDialog, + showPlaybackSettingsDialog = showPlaybackSettingsDialog, + showPlaybackSpeedDialog = showPlaybackSpeedDialog, + showResolutionDialog = showResolutionDialog, + title = title, + subtitleDialogState = subtitleDialogState, + subtitlesEnabled = subtitlesEnabled, + hasEmbeddedSubtitles = hasEmbeddedSubtitles, + openSubtitlesApiKey = openSubtitlesApiKey, + openSubtitlesUserAgent = openSubtitlesUserAgent, + audioBoostDb = audioBoostDb, + currentContentType = currentContentType, + matchFrameRateEnabled = matchFrameRateEnabled, + showNerdStats = showNerdStats, + player = player, + playbackEngine = playbackEngine, + subtitleRepository = subtitleRepository, + subtitleCoroutineScope = subtitleCoroutineScope, + mediaId = mediaId, + onRequestOpenSubtitlesApiKey = onRequestOpenSubtitlesApiKey, + publishSubtitleState = ::publishSubtitleState, + toggleSubtitles = toggleSubtitles, + onMatchFrameRateChange = onMatchFrameRateChange, + onShowSubtitleDialogChange = { showSubtitleDialog = it }, + onSubtitleDialogStateChange = { subtitleDialogState = it }, + onSubtitlesEnabledChange = { subtitlesEnabled = it }, + onActiveSubtitleChange = { activeSubtitle = it }, + onSubtitleOffsetMsReset = { subtitleOffsetMs = 0L }, + onLastAppliedOffsetMsReset = { lastAppliedOffsetMs = 0L }, + onShowSubtitleTrackDialogChange = { showSubtitleTrackDialog = it }, + onShowSubtitleOptionsDialogChange = { showSubtitleOptionsDialog = it }, + onShowAudioTrackDialogChange = { showAudioTrackDialog = it }, + onAudioBoostChange = { audioBoostDb = it }, + onShowAudioBoostDialogChange = { showAudioBoostDialog = it }, + onShowPlaybackSettingsDialogChange = { showPlaybackSettingsDialog = it }, + onShowNerdStatsChange = { showNerdStats = it }, + onShowPlaybackSpeedDialogChange = { showPlaybackSpeedDialog = it }, + onShowResolutionDialogChange = { showResolutionDialog = it }, + ) +} + +@Composable +private fun BoxScope.PlayerBoxOverlays( + showNextEpisodeOverlay: Boolean, + nextEpisodeTitle: String?, + countdownRemaining: Int, + controlsVisible: Boolean, + currentContentType: ContentType?, + showVodStartupIndicator: Boolean, + showNerdStats: Boolean, + nerdStats: PlayerNerdStats?, + showSubtitleTimingDialog: Boolean, + activeSubtitle: ActiveSubtitle?, + subtitleOffsetMs: Long, + subtitleTimingSessionStartOffsetMs: Long, + lastAppliedOffsetMs: Long, + isDualScreenActive: Boolean, + dualScreenFullscreenTile: DualTile?, + primaryPlayer: Player, + secondaryPlayer: ExoPlayer?, + primaryChannel: ContentItem?, + rightChannel: ContentItem?, + focusedTile: DualTile, + leftTileState: DualTilePlayerState, + rightTileState: DualTilePlayerState, + actionMenuTile: DualTile?, + isGuideOpen: Boolean, + activeChannelId: String?, + liveGuideLevel: LiveGuideLevel, + liveGuideCategories: List, + liveGuideChannels: List, + liveGuideCategoryIndex: Int, + liveGuideChannelIndex: Int, + liveGuideLoading: Boolean, + liveGuideErrorMessage: String?, + liveGuideSelectedCategoryName: String?, + liveGuideSearchQuery: String, + liveGuideSearchFocused: Boolean, + liveGuideSearchEditing: Boolean, + liveGuideSearchEditRequestNonce: Int, + liveGuideSearchChannelsLoading: Boolean, + liveGuideSearchActive: Boolean, + liveGuideSearchRows: List, + liveGuideSearchResultIndex: Int, + selectedLiveGuideTickerText: String?, + selectedLiveGuideTickerLoading: Boolean, + liveGuideReturnCategoryAtTop: Boolean, + onDismissNextEpisodePrompt: () -> Unit, + onSubtitleTimingOffsetChange: (Long) -> Unit, + onSubtitleTimingCancel: () -> Unit, + onSubtitleTimingDismiss: () -> Unit, + onOpenActionMenu: (DualTile) -> Unit, + onCloseActionMenu: () -> Unit, + onFocusChange: (DualTile) -> Unit, + onEnterFullscreen: (DualTile) -> Unit, + onOpenChannelPicker: (DualTile) -> Unit, + onCloseTile: (DualTile) -> Unit, + onRestartStream: (DualTile) -> Unit, + onRetryError: (DualTile) -> Unit, + onExitDualScreen: () -> Unit, + onGuideMove: (Int) -> Unit, + onGuideSelect: () -> Unit, + onGuideBack: () -> Unit, + onGuideDismiss: () -> Unit, + onGuideSearchKey: (KeyEvent) -> Boolean, + onSearchQueryChange: (String) -> Unit, + onSearchMoveUp: () -> Unit, + onSearchMoveDown: () -> Unit, + onSearchStopEditing: () -> Unit, + onCategorySelectionAligned: () -> Unit, + categoryThumbnailProvider: (CategoryItem) -> String?, + onCategoryThumbnailNeeded: (CategoryItem) -> Unit, +) { + if (showNextEpisodeOverlay && nextEpisodeTitle != null) { + NextEpisodeOverlay( + nextEpisodeTitle = nextEpisodeTitle, + countdownSeconds = NEXT_EPISODE_COUNTDOWN_SECONDS, + remainingSeconds = countdownRemaining, + controlsVisible = controlsVisible, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } + + if (currentContentType != ContentType.LIVE && showVodStartupIndicator) { + Box( + modifier = Modifier + .align(Alignment.Center) + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.55f)) + .border(1.dp, AppTheme.colors.borderStrong, RoundedCornerShape(14.dp)) + .padding(horizontal = 20.dp, vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.width(28.dp).height(28.dp), + color = AppTheme.colors.accent, + strokeWidth = 3.dp + ) + Text( + text = "Loading video...", + color = Color.White, + fontSize = 15.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium + ) + } } + } + if (showNerdStats) { + PlayerNerdStatsPanel( + stats = nerdStats, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 12.dp, top = 64.dp) + ) } + if (showSubtitleTimingDialog && activeSubtitle != null && isOffsetSupported(activeSubtitle.fileName)) { + SubtitleOffsetDialog( + offsetMs = subtitleOffsetMs, + onOffsetChange = { newOffset -> + if (newOffset == subtitleOffsetMs) return@SubtitleOffsetDialog + onSubtitleTimingOffsetChange(newOffset) + }, + onCancel = onSubtitleTimingCancel, + onDismiss = onSubtitleTimingDismiss + ) + } + + if (isDualScreenActive && dualScreenFullscreenTile == null) { + DualLiveScreen( + primaryPlayer = primaryPlayer, + secondaryPlayer = secondaryPlayer, + primaryChannel = primaryChannel, + rightChannel = rightChannel, + focusedTile = focusedTile, + leftTileState = leftTileState, + rightTileState = rightTileState, + actionMenuTile = actionMenuTile, + onOpenActionMenu = onOpenActionMenu, + onCloseActionMenu = onCloseActionMenu, + onFocusChange = onFocusChange, + onEnterFullscreen = onEnterFullscreen, + onOpenChannelPicker = onOpenChannelPicker, + onCloseTile = onCloseTile, + onRestartStream = onRestartStream, + onRetryError = onRetryError, + onExitDualScreen = onExitDualScreen, + isGuideOpen = isGuideOpen, + onGuideMove = onGuideMove, + onGuideSelect = onGuideSelect, + onGuideBack = onGuideBack, + onGuideDismiss = onGuideDismiss, + onGuideSearchKey = onGuideSearchKey, + modifier = Modifier.fillMaxSize() + ) + } + + if (isGuideOpen && currentContentType == ContentType.LIVE) { + LiveGuidePanel( + level = liveGuideLevel, + categories = liveGuideCategories, + channels = liveGuideChannels, + selectedCategoryIndex = liveGuideCategoryIndex, + selectedChannelIndex = liveGuideChannelIndex, + activeChannelId = activeChannelId, + isLoading = liveGuideLoading, + errorMessage = liveGuideErrorMessage, + selectedCategoryName = liveGuideSelectedCategoryName, + searchQuery = liveGuideSearchQuery, + searchFocused = liveGuideSearchFocused, + searchEditing = liveGuideSearchEditing, + searchEditRequestNonce = liveGuideSearchEditRequestNonce, + searchLoading = liveGuideSearchChannelsLoading, + searchActive = liveGuideSearchActive, + searchRows = liveGuideSearchRows, + selectedSearchIndex = liveGuideSearchResultIndex, + onSearchQueryChange = onSearchQueryChange, + onSearchMoveUp = onSearchMoveUp, + onSearchMoveDown = onSearchMoveDown, + onSearchStopEditing = onSearchStopEditing, + selectedChannelTickerText = selectedLiveGuideTickerText, + selectedChannelTickerLoading = selectedLiveGuideTickerLoading, + alignCategorySelectionToTop = liveGuideReturnCategoryAtTop, + onCategorySelectionAligned = onCategorySelectionAligned, + categoryThumbnailProvider = categoryThumbnailProvider, + onCategoryThumbnailNeeded = onCategoryThumbnailNeeded + ) + } +} +@Composable +private fun PlayerDialogsSection( + showSubtitleDialog: Boolean, + showSubtitleOptionsDialog: Boolean, + showSubtitleTrackDialog: Boolean, + showAudioTrackDialog: Boolean, + showAudioBoostDialog: Boolean, + showPlaybackSettingsDialog: Boolean, + showPlaybackSpeedDialog: Boolean, + showResolutionDialog: Boolean, + title: String, + subtitleDialogState: SubtitleDialogState, + subtitlesEnabled: Boolean, + hasEmbeddedSubtitles: Boolean, + openSubtitlesApiKey: String, + openSubtitlesUserAgent: String, + audioBoostDb: Float, + currentContentType: ContentType?, + matchFrameRateEnabled: Boolean, + showNerdStats: Boolean, + player: Player, + playbackEngine: Media3PlaybackEngine, + subtitleRepository: SubtitleRepository, + subtitleCoroutineScope: CoroutineScope, + mediaId: String, + onRequestOpenSubtitlesApiKey: () -> Unit, + publishSubtitleState: (ActiveSubtitle?, Long) -> Unit, + toggleSubtitles: () -> Unit, + onMatchFrameRateChange: (Boolean) -> Unit, + onShowSubtitleDialogChange: (Boolean) -> Unit, + onSubtitleDialogStateChange: (SubtitleDialogState) -> Unit, + onSubtitlesEnabledChange: (Boolean) -> Unit, + onActiveSubtitleChange: (ActiveSubtitle?) -> Unit, + onSubtitleOffsetMsReset: () -> Unit, + onLastAppliedOffsetMsReset: () -> Unit, + onShowSubtitleTrackDialogChange: (Boolean) -> Unit, + onShowSubtitleOptionsDialogChange: (Boolean) -> Unit, + onShowAudioTrackDialogChange: (Boolean) -> Unit, + onAudioBoostChange: (Float) -> Unit, + onShowAudioBoostDialogChange: (Boolean) -> Unit, + onShowPlaybackSettingsDialogChange: (Boolean) -> Unit, + onShowNerdStatsChange: (Boolean) -> Unit, + onShowPlaybackSpeedDialogChange: (Boolean) -> Unit, + onShowResolutionDialogChange: (Boolean) -> Unit, +) { if (showSubtitleDialog) { val subtitleQuery = remember(title) { sanitizeSubtitleQuery(title) } SubtitleSearchDialog( - initialQuery = subtitleQuery, - state = subtitleDialogState, - subtitlesEnabled = subtitlesEnabled, - embeddedSubtitlesAvailable = hasEmbeddedSubtitles, - onSearch = { query -> - if (openSubtitlesApiKey.isBlank()) { - showSubtitleDialog = false - subtitleDialogState = SubtitleDialogState.Idle - onRequestOpenSubtitlesApiKey() - return@SubtitleSearchDialog - } - subtitleDialogState = SubtitleDialogState.Searching - subtitleCoroutineScope.launch { - val result = - subtitleRepository.searchSubtitles( - apiKey = openSubtitlesApiKey, - userAgent = openSubtitlesUserAgent, - title = query - ) - subtitleDialogState = - result.fold( - onSuccess = { subtitles -> - if (subtitles.isEmpty()) { - SubtitleDialogState.Results(emptyList()) - } else { - SubtitleDialogState.Results(subtitles) - } - }, - onFailure = { error -> - SubtitleDialogState.Error( - error.message ?: "Search failed" - ) - } - ) - } - }, - onSelect = { subtitle -> - subtitleDialogState = SubtitleDialogState.Downloading(subtitle) - subtitleCoroutineScope.launch { - val result = - subtitleRepository.downloadAndCacheSubtitle( - apiKey = openSubtitlesApiKey, - userAgent = openSubtitlesUserAgent, - subtitle = subtitle, - mediaId = mediaId - ) - if (result.isSuccess) { - val cachedSubtitle = result.getOrThrow() - // Read original content for offset adjustment - val originalContent = - withContext(Dispatchers.IO) { - try { - java.io.File(cachedSubtitle.uri.path ?: "").readBytes() - } catch (e: Exception) { - null - } - } - activeSubtitle = - ActiveSubtitle( - uri = cachedSubtitle.uri, - language = cachedSubtitle.language, - label = subtitle.release, - fileName = cachedSubtitle.fileName, - originalContent = originalContent - ) - subtitleOffsetMs = 0L // Reset offset for new subtitle - lastAppliedOffsetMs = 0L - val mimeType = subtitleMimeType(cachedSubtitle.fileName) - playbackEngine.addSubtitle( - subtitleUri = cachedSubtitle.uri, - language = cachedSubtitle.language, - label = subtitle.release, - mimeType = mimeType - ) - publishSubtitleState(activeSubtitle, 0L) - subtitlesEnabled = true - showSubtitleDialog = false - subtitleDialogState = SubtitleDialogState.Idle - } else { - val error = result.exceptionOrNull() - subtitleDialogState = - SubtitleDialogState.Error( - error?.message ?: "Download failed" - ) - } + initialQuery = subtitleQuery, + state = subtitleDialogState, + subtitlesEnabled = subtitlesEnabled, + embeddedSubtitlesAvailable = hasEmbeddedSubtitles, + onSearch = { query -> + if (openSubtitlesApiKey.isBlank()) { + onShowSubtitleDialogChange(false) + onSubtitleDialogStateChange(SubtitleDialogState.Idle) + onRequestOpenSubtitlesApiKey() + return@SubtitleSearchDialog + } + onSubtitleDialogStateChange(SubtitleDialogState.Searching) + subtitleCoroutineScope.launch { + val result = + subtitleRepository.searchSubtitles( + apiKey = openSubtitlesApiKey, + userAgent = openSubtitlesUserAgent, + title = query + ) + onSubtitleDialogStateChange( + result.fold( + onSuccess = { subtitles -> + if (subtitles.isEmpty()) SubtitleDialogState.Results(emptyList()) + else SubtitleDialogState.Results(subtitles) + }, + onFailure = { error -> + SubtitleDialogState.Error(error.message ?: "Search failed") + } + ) + ) + } + }, + onSelect = { subtitle -> + onSubtitleDialogStateChange(SubtitleDialogState.Downloading(subtitle)) + subtitleCoroutineScope.launch { + val result = + subtitleRepository.downloadAndCacheSubtitle( + apiKey = openSubtitlesApiKey, + userAgent = openSubtitlesUserAgent, + subtitle = subtitle, + mediaId = mediaId + ) + if (result.isSuccess) { + val cachedSubtitle = result.getOrThrow() + val originalContent = + withContext(Dispatchers.IO) { + try { + java.io.File(cachedSubtitle.uri.path ?: "").readBytes() + } catch (e: Exception) { + null + } + } + val newSubtitle = ActiveSubtitle( + uri = cachedSubtitle.uri, + language = cachedSubtitle.language, + label = subtitle.release, + fileName = cachedSubtitle.fileName, + originalContent = originalContent + ) + onActiveSubtitleChange(newSubtitle) + onSubtitleOffsetMsReset() + onLastAppliedOffsetMsReset() + val mimeType = subtitleMimeType(cachedSubtitle.fileName) + playbackEngine.addSubtitle( + subtitleUri = cachedSubtitle.uri, + language = cachedSubtitle.language, + label = subtitle.release, + mimeType = mimeType + ) + publishSubtitleState(newSubtitle, 0L) + onSubtitlesEnabledChange(true) + onShowSubtitleDialogChange(false) + onSubtitleDialogStateChange(SubtitleDialogState.Idle) + } else { + val error = result.exceptionOrNull() + onSubtitleDialogStateChange( + SubtitleDialogState.Error(error?.message ?: "Download failed") + ) } - }, - onToggleSubtitles = { toggleSubtitles() }, - onSelectSubtitleTrack = { - showSubtitleDialog = false - showSubtitleTrackDialog = true - }, - onDismiss = { - showSubtitleDialog = false - subtitleDialogState = SubtitleDialogState.Idle } + }, + onToggleSubtitles = { toggleSubtitles() }, + onSelectSubtitleTrack = { + onShowSubtitleDialogChange(false) + onShowSubtitleTrackDialogChange(true) + }, + onDismiss = { + onShowSubtitleDialogChange(false) + onSubtitleDialogStateChange(SubtitleDialogState.Idle) + } ) } @@ -2300,115 +3073,110 @@ internal fun PlayerOverlay( val availableSubtitleTracks = playbackEngine.getAvailableSubtitleTracks() val canSelectSubtitleTrack = availableSubtitleTracks.count { it.isSupported } > 1 SubtitleOptionsDialog( - subtitlesEnabled = subtitlesEnabled, - showSubtitleTrackOption = canSelectSubtitleTrack, - onToggleSubtitles = { - toggleSubtitles() - showSubtitleOptionsDialog = false - }, - onSelectSubtitleTrack = { - showSubtitleOptionsDialog = false - showSubtitleTrackDialog = true - }, - onDownloadSubtitles = { - showSubtitleOptionsDialog = false - showSubtitleDialog = true - }, - onDismiss = { showSubtitleOptionsDialog = false } + subtitlesEnabled = subtitlesEnabled, + showSubtitleTrackOption = canSelectSubtitleTrack, + onToggleSubtitles = { + toggleSubtitles() + onShowSubtitleOptionsDialogChange(false) + }, + onSelectSubtitleTrack = { + onShowSubtitleOptionsDialogChange(false) + onShowSubtitleTrackDialogChange(true) + }, + onDownloadSubtitles = { + onShowSubtitleOptionsDialogChange(false) + onShowSubtitleDialogChange(true) + }, + onDismiss = { onShowSubtitleOptionsDialogChange(false) } ) } if (showSubtitleTrackDialog) { SubtitleTrackDialog( - availableTracks = playbackEngine.getAvailableSubtitleTracks(), - onTrackSelected = { groupIndex, trackIndex -> - playbackEngine.selectSubtitleTrack(groupIndex, trackIndex) - }, - onDismiss = { showSubtitleTrackDialog = false } + availableTracks = playbackEngine.getAvailableSubtitleTracks(), + onTrackSelected = { groupIndex, trackIndex -> + playbackEngine.selectSubtitleTrack(groupIndex, trackIndex) + }, + onDismiss = { onShowSubtitleTrackDialogChange(false) } ) } if (showAudioTrackDialog) { AudioTrackDialog( - availableTracks = playbackEngine.getAvailableAudioTracks(), - onTrackSelected = { groupIndex, trackIndex -> - playbackEngine.selectAudioTrack(groupIndex, trackIndex) - }, - onDismiss = { showAudioTrackDialog = false } + availableTracks = playbackEngine.getAvailableAudioTracks(), + onTrackSelected = { groupIndex, trackIndex -> + playbackEngine.selectAudioTrack(groupIndex, trackIndex) + }, + onDismiss = { onShowAudioTrackDialogChange(false) } ) } if (showAudioBoostDialog) { AudioBoostDialog( - boostDb = audioBoostDb, - onBoostChange = { newBoost -> - audioBoostDb = newBoost - playbackEngine.setAudioBoostDb(newBoost) - }, - onDismiss = { showAudioBoostDialog = false } + boostDb = audioBoostDb, + onBoostChange = { newBoost -> + onAudioBoostChange(newBoost) + playbackEngine.setAudioBoostDb(newBoost) + }, + onDismiss = { onShowAudioBoostDialogChange(false) } ) } if (showPlaybackSettingsDialog) { val videoTracks = playbackEngine.getAvailableVideoTracks() val selectedResolution = - videoTracks.firstOrNull { it.isSelected }?.label - ?: if (videoTracks.isEmpty()) "Unknown" else videoTracks.first().label + videoTracks.firstOrNull { it.isSelected }?.label + ?: if (videoTracks.isEmpty()) "Unknown" else videoTracks.first().label val speedLabel = "${player.playbackParameters.speed}x" - val resolutionMode = - if (playbackEngine.isVideoOverrideActive()) { - "Manual" - } else { - "Auto" - } - val resolutionLabel = "$resolutionMode \u2022 $selectedResolution" + val resolutionMode = if (playbackEngine.isVideoOverrideActive()) "Manual" else "Auto" + val resolutionLabel = "$resolutionMode • $selectedResolution" val showSpeedOption = currentContentType != ContentType.LIVE val matchFrameRateLabel = if (matchFrameRateEnabled) "On" else "Off" val nerdStatsLabel = if (showNerdStats) "On" else "Off" PlaybackSettingsDialog( - speedLabel = speedLabel, - resolutionLabel = resolutionLabel, - matchFrameRateLabel = matchFrameRateLabel, - nerdStatsLabel = nerdStatsLabel, - showSpeedOption = showSpeedOption, - onSpeed = { - showPlaybackSettingsDialog = false - showPlaybackSpeedDialog = true - }, - onResolution = { - showPlaybackSettingsDialog = false - showResolutionDialog = true - }, - onToggleMatchFrameRate = { onMatchFrameRateChange(!matchFrameRateEnabled) }, - onToggleNerdStats = { showNerdStats = !showNerdStats }, - onDismiss = { showPlaybackSettingsDialog = false } + speedLabel = speedLabel, + resolutionLabel = resolutionLabel, + matchFrameRateLabel = matchFrameRateLabel, + nerdStatsLabel = nerdStatsLabel, + showSpeedOption = showSpeedOption, + onSpeed = { + onShowPlaybackSettingsDialogChange(false) + onShowPlaybackSpeedDialogChange(true) + }, + onResolution = { + onShowPlaybackSettingsDialogChange(false) + onShowResolutionDialogChange(true) + }, + onToggleMatchFrameRate = { onMatchFrameRateChange(!matchFrameRateEnabled) }, + onToggleNerdStats = { onShowNerdStatsChange(!showNerdStats) }, + onDismiss = { onShowPlaybackSettingsDialogChange(false) } ) } if (showPlaybackSpeedDialog) { if (currentContentType == ContentType.LIVE) { - showPlaybackSpeedDialog = false + onShowPlaybackSpeedDialogChange(false) } else { PlaybackSpeedDialog( - currentSpeed = player.playbackParameters.speed, - onSpeedSelected = { speed -> - player.setPlaybackParameters( - androidx.media3.common.PlaybackParameters(speed) - ) - }, - onDismiss = { showPlaybackSpeedDialog = false } + currentSpeed = player.playbackParameters.speed, + onSpeedSelected = { speed -> + player.setPlaybackParameters( + androidx.media3.common.PlaybackParameters(speed) + ) + }, + onDismiss = { onShowPlaybackSpeedDialogChange(false) } ) } } if (showResolutionDialog) { VideoResolutionDialog( - availableTracks = playbackEngine.getAvailableVideoTracks(), - onTrackSelected = { groupIndex, trackIndex -> - playbackEngine.selectVideoTrack(groupIndex, trackIndex) - }, - onDismiss = { showResolutionDialog = false } + availableTracks = playbackEngine.getAvailableVideoTracks(), + onTrackSelected = { groupIndex, trackIndex -> + playbackEngine.selectVideoTrack(groupIndex, trackIndex) + }, + onDismiss = { onShowResolutionDialogChange(false) } ) } } diff --git a/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt b/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt index a696a35..609b3e8 100644 --- a/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/PlayerScreen.kt @@ -1,5 +1,6 @@ package com.example.xtreamplayer +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -32,6 +33,8 @@ internal fun PlayerScreen( onLiveChannelSwitch: (Int) -> Boolean, onLiveGuideChannelSelect: (ContentItem, List) -> Unit, onSubtitleStateChanged: (PlaybackSubtitleState?) -> Unit, + dualLiveEnabled: Boolean, + loadLiveStreamUri: suspend (ContentItem) -> Uri?, loadLiveNowNext: suspend (ContentItem) -> Result, loadLiveCategories: suspend () -> Result>, loadLiveCategoryChannels: suspend (CategoryItem) -> Result>, @@ -81,6 +84,8 @@ internal fun PlayerScreen( onLiveChannelSwitch = onLiveChannelSwitch, onLiveGuideChannelSelect = onLiveGuideChannelSelect, onSubtitleStateChanged = onSubtitleStateChanged, + dualLiveEnabled = dualLiveEnabled, + loadLiveStreamUri = loadLiveStreamUri, loadLiveNowNext = loadLiveNowNext, loadLiveCategories = loadLiveCategories, loadLiveCategoryChannels = loadLiveCategoryChannels, diff --git a/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt b/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt index 161863e..fd2400e 100644 --- a/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt +++ b/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt @@ -82,7 +82,8 @@ fun SettingsScreen( onToggleCheckUpdatesOnStartup: () -> Unit, onCheckForUpdates: () -> Unit, onClearCache: () -> Unit, - onSignOut: () -> Unit + onSignOut: () -> Unit, + onToggleDualLiveEnabled: () -> Unit ) { val shape = RoundedCornerShape(18.dp) val scrollState = rememberScrollState() @@ -114,6 +115,13 @@ fun SettingsScreen( SettingsAction("Sync library", null, onRefreshContent), SettingsAction("Refresh on startup", flagLabel(settings.refreshOnStartup), onToggleRefreshOnStartup) ) + val liveActions = listOf( + SettingsAction( + "Dual Screen Live", + if (settings.dualLiveEnabled) "On - uses 2 streams" else "Off - hidden", + onToggleDualLiveEnabled + ) + ) val aboutActions = listOf( SettingsAction( "Check for updates on startup", @@ -197,6 +205,9 @@ fun SettingsScreen( SettingsSectionHeader("Playback") playbackActions.forEach { action -> renderAction(action) } + SettingsSectionHeader("Live") + liveActions.forEach { action -> renderAction(action) } + SettingsSectionHeader("Appearance") appearanceActions.forEach { action -> renderAction(action) } diff --git a/app/src/main/java/com/example/xtreamplayer/content/SearchPolicy.kt b/app/src/main/java/com/example/xtreamplayer/content/SearchPolicy.kt new file mode 100644 index 0000000..284be26 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/SearchPolicy.kt @@ -0,0 +1,12 @@ +package com.example.xtreamplayer.content + +object SearchPolicy { + const val MIN_QUERY_LENGTH = 2 + + // Uses compact length (spaces stripped) so punctuation-split queries like "a e" (from "A&E") + // count as 2 real characters and are allowed through. + fun isSearchableNormalizedQuery(normalized: String): Boolean { + if (normalized.isBlank()) return false + return normalized.replace(" ", "").length >= MIN_QUERY_LENGTH + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/player/Media3PlaybackEngine.kt b/app/src/main/java/com/example/xtreamplayer/player/Media3PlaybackEngine.kt index 9885d0d..56f8120 100644 --- a/app/src/main/java/com/example/xtreamplayer/player/Media3PlaybackEngine.kt +++ b/app/src/main/java/com/example/xtreamplayer/player/Media3PlaybackEngine.kt @@ -105,8 +105,10 @@ class Media3PlaybackEngine( lastSettings = settings hasAppliedSettings = true - // Auto-play is handled manually in UI for series episodes only - if (isFirstApply || player.repeatMode != Player.REPEAT_MODE_OFF) { + // Auto-play is handled manually in UI for series episodes only. + // Only reset on first apply — subsequent applies must not clobber a repeat-mode pin + // set by dual-screen mode (which uses REPEAT_MODE_ONE to prevent live queue advance). + if (isFirstApply) { player.repeatMode = Player.REPEAT_MODE_OFF } @@ -167,6 +169,16 @@ class Media3PlaybackEngine( rebuildPlayerPreservingState() } + fun createSecondaryLivePlayer(): ExoPlayer { + return buildPlayer( + loadControl = buildLoadControl(BufferProfile.LIVE_SECONDARY, lastSettings), + handleAudioFocus = false, + attachEngineListener = false + ).apply { + volume = 0f + } + } + fun getAudioBoostDb(): Float = boostDb fun setAudioBoostDb(db: Float) { @@ -218,7 +230,11 @@ class Media3PlaybackEngine( } } - private fun buildPlayer(loadControl: DefaultLoadControl): ExoPlayer { + private fun buildPlayer( + loadControl: DefaultLoadControl, + handleAudioFocus: Boolean = true, + attachEngineListener: Boolean = true + ): ExoPlayer { return ExoPlayer.Builder(appContext, renderersFactory) .setLoadControl(loadControl) .setMediaSourceFactory(mediaSourceFactory) @@ -226,10 +242,12 @@ class Media3PlaybackEngine( .setSeekForwardIncrementMs(SEEK_FORWARD_INCREMENT_MS) .build() .apply { - setAudioAttributes(audioAttributes, true) + setAudioAttributes(audioAttributes, handleAudioFocus) setHandleAudioBecomingNoisy(true) volume = 1f - addListener(playerListener) + if (attachEngineListener) { + addListener(playerListener) + } } } @@ -1001,6 +1019,14 @@ enum class BufferProfile( bufferForPlaybackMs = 1_000, bufferForPlaybackAfterRebufferMs = 2_000 ), + // Smaller buffer for the secondary (muted/background) split-screen player. + // Reduces peak memory when two streams run simultaneously on low-RAM devices. + LIVE_SECONDARY( + minBufferMs = 2_000, + maxBufferMs = 8_000, + bufferForPlaybackMs = 800, + bufferForPlaybackAfterRebufferMs = 1_500 + ), VOD( minBufferMs = 25_000, maxBufferMs = 120_000, diff --git a/app/src/main/java/com/example/xtreamplayer/player/XtreamPlayerView.kt b/app/src/main/java/com/example/xtreamplayer/player/XtreamPlayerView.kt index 63cd9a2..2d2e764 100644 --- a/app/src/main/java/com/example/xtreamplayer/player/XtreamPlayerView.kt +++ b/app/src/main/java/com/example/xtreamplayer/player/XtreamPlayerView.kt @@ -56,6 +56,7 @@ class XtreamPlayerView @JvmOverloads constructor( private var audioTrackView: View? = null private var audioBoostView: View? = null private var settingsView: View? = null + private var dualScreenButtonView: View? = null private var prevButtonView: View? = null private var nextButtonView: View? = null private var titleView: TextView? = null @@ -158,6 +159,17 @@ class XtreamPlayerView @JvmOverloads constructor( field = value updateControlsForContentType() } + var isDualLiveEnabled: Boolean = false + set(value) { + if (field == value) return + field = value + updateControlsForContentType() + } + var onDualScreenClick: (() -> Unit)? = null + set(value) { + field = value + bindDualScreenView() + } var forcedAspectRatio: Float? = null set(value) { field = value @@ -239,6 +251,7 @@ class XtreamPlayerView @JvmOverloads constructor( bindAudioTrackView() bindAudioBoostView() bindSettingsView() + bindDualScreenView() bindTitleView() bindNowPlayingInfoView() bindBottomTimeViews() @@ -736,6 +749,7 @@ class XtreamPlayerView @JvmOverloads constructor( setViewVisible(Media3UiR.id.exo_subtitle, false) setViewVisible(R.id.exo_subtitle_timing, !isLive) setViewVisible(R.id.exo_subtitle_download, !isLive) + setViewVisible(R.id.exo_dual_screen, isLive && isDualLiveEnabled) findViewById(R.id.exo_live_progress_line)?.visibility = if (isLive) View.VISIBLE else View.GONE bindPrevNextView() @@ -877,6 +891,7 @@ class XtreamPlayerView @JvmOverloads constructor( R.id.exo_audio_track, R.id.exo_audio_boost, R.id.exo_resize_mode, + R.id.exo_dual_screen, Media3UiR.id.exo_settings, Media3UiR.id.exo_fullscreen, Media3UiR.id.exo_overflow_show @@ -952,6 +967,7 @@ class XtreamPlayerView @JvmOverloads constructor( R.id.exo_audio_track, R.id.exo_audio_boost, R.id.exo_resize_mode, + R.id.exo_dual_screen, Media3UiR.id.exo_settings, Media3UiR.id.exo_fullscreen, Media3UiR.id.exo_overflow_show @@ -1120,6 +1136,13 @@ class XtreamPlayerView @JvmOverloads constructor( view?.setOnClickListener { onSettingsClick?.invoke() } } + private fun bindDualScreenView() { + val view = dualScreenButtonView ?: findViewById(R.id.exo_dual_screen).also { + dualScreenButtonView = it + } + view?.setOnClickListener { onDualScreenClick?.invoke() } + } + private fun bindPrevNextView() { val prev = prevButtonView ?: findViewById(Media3UiR.id.exo_prev).also { prevButtonView = it diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt index 3e176fd..d9c7716 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt @@ -51,6 +51,7 @@ class SettingsRepository(private val context: Context) { val clockFormat = parseClockFormat(prefs[Keys.CLOCK_FORMAT]) val openSubtitlesApiKey = prefs[Keys.OPENSUBTITLES_API_KEY] ?: "" val openSubtitlesUserAgent = prefs[Keys.OPENSUBTITLES_USER_AGENT] ?: "" + val dualLiveEnabled = prefs[Keys.DUAL_LIVE_ENABLED] ?: false cacheBootSettings(appTheme, appFont, uiScale, fontScale) @@ -74,7 +75,8 @@ class SettingsRepository(private val context: Context) { fontScale = fontScale, clockFormat = clockFormat, openSubtitlesApiKey = openSubtitlesApiKey, - openSubtitlesUserAgent = openSubtitlesUserAgent + openSubtitlesUserAgent = openSubtitlesUserAgent, + dualLiveEnabled = dualLiveEnabled ) } @@ -274,6 +276,12 @@ class SettingsRepository(private val context: Context) { } } + suspend fun setDualLiveEnabled(enabled: Boolean) { + context.dataStore.edit { prefs -> + prefs[Keys.DUAL_LIVE_ENABLED] = enabled + } + } + suspend fun isStartupUpdateCheckEnabled(): Boolean { return context.dataStore.data.firstOrNull()?.get(Keys.CHECK_UPDATES_ON_STARTUP) ?: true } @@ -351,6 +359,7 @@ class SettingsRepository(private val context: Context) { val CLOCK_FORMAT = stringPreferencesKey("clock_format") val OPENSUBTITLES_API_KEY = stringPreferencesKey("opensubtitles_api_key") val OPENSUBTITLES_USER_AGENT = stringPreferencesKey("opensubtitles_user_agent") + val DUAL_LIVE_ENABLED = booleanPreferencesKey("dual_live_enabled") const val BOOT_APP_THEME = "boot_app_theme" const val BOOT_APP_FONT = "boot_app_font" const val BOOT_UI_SCALE = "boot_ui_scale" diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt index 5f25564..4280ce2 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt @@ -71,7 +71,8 @@ data class SettingsState( val fontScale: Float = 1.0f, val clockFormat: ClockFormatOption = ClockFormatOption.AM_PM, val openSubtitlesApiKey: String = "", - val openSubtitlesUserAgent: String = "" + val openSubtitlesUserAgent: String = "", + val dualLiveEnabled: Boolean = false ) fun vodBufferLabel(seconds: Int): String = "${seconds.coerceIn(5, 300)}s" diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt index 3294c24..a85a025 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt @@ -70,6 +70,12 @@ class SettingsViewModel @Inject constructor( } } + fun toggleDualLiveEnabled() { + viewModelScope.launch { + repository.setDualLiveEnabled(!settings.value.dualLiveEnabled) + } + } + fun setSubtitleCacheAutoClearInterval(intervalMs: Long) { viewModelScope.launch { repository.setSubtitleCacheAutoClearInterval(intervalMs) diff --git a/app/src/main/java/com/example/xtreamplayer/ui/SearchDebounce.kt b/app/src/main/java/com/example/xtreamplayer/ui/SearchDebounce.kt index 3ab3b0c..d89534e 100644 --- a/app/src/main/java/com/example/xtreamplayer/ui/SearchDebounce.kt +++ b/app/src/main/java/com/example/xtreamplayer/ui/SearchDebounce.kt @@ -7,10 +7,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.example.xtreamplayer.content.SearchNormalizer +import com.example.xtreamplayer.content.SearchPolicy import kotlinx.coroutines.delay private const val SEARCH_DEBOUNCE_MS = 800L -private const val MIN_SEARCH_LENGTH = 3 class DebouncedSearchState( initialQuery: String = "", @@ -22,7 +22,7 @@ class DebouncedSearchState( fun performSearch() { val normalized = SearchNormalizer.normalizeQuery(query) - debouncedQuery = if (normalized.length >= MIN_SEARCH_LENGTH) normalized else "" + debouncedQuery = if (SearchPolicy.isSearchableNormalizedQuery(normalized)) normalized else "" } fun clear() { @@ -43,13 +43,10 @@ fun rememberDebouncedSearchState( LaunchedEffect(state.query) { val normalized = SearchNormalizer.normalizeQuery(state.query) - if (normalized.isBlank()) { + if (!SearchPolicy.isSearchableNormalizedQuery(normalized)) { state.debouncedQuery = "" return@LaunchedEffect } - if (normalized.length < MIN_SEARCH_LENGTH) { - return@LaunchedEffect - } delay(SEARCH_DEBOUNCE_MS) state.debouncedQuery = normalized } diff --git a/app/src/main/res/drawable/ic_dual_screen.xml b/app/src/main/res/drawable/ic_dual_screen.xml new file mode 100644 index 0000000..1c526d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_dual_screen.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index a65daeb..c298d14 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -272,6 +272,15 @@ android:focusable="true" android:clickable="true" /> + + Subtitle timing Select audio track Adjust audio boost + Dual screen diff --git a/app/src/test/java/com/example/xtreamplayer/content/SearchNormalizerTest.kt b/app/src/test/java/com/example/xtreamplayer/content/SearchNormalizerTest.kt index 02d02c4..ff88d38 100644 --- a/app/src/test/java/com/example/xtreamplayer/content/SearchNormalizerTest.kt +++ b/app/src/test/java/com/example/xtreamplayer/content/SearchNormalizerTest.kt @@ -1,6 +1,7 @@ package com.example.xtreamplayer.content import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -30,4 +31,37 @@ class SearchNormalizerTest { assertEquals("planet earth", SearchNormalizer.normalizeQuery("EN-Planet Earth")) assertEquals("le bureau", SearchNormalizer.normalizeQuery("fr | Le Bureau")) } + + @Test + fun normalizeQuery_handles_short_alphanumeric_queries() { + assertEquals("4k", SearchNormalizer.normalizeQuery("4K")) + assertEquals("uhd", SearchNormalizer.normalizeQuery("UHD")) + assertEquals("fhd", SearchNormalizer.normalizeQuery("FHD")) + assertEquals("hd", SearchNormalizer.normalizeQuery("HD")) + assertEquals("f1", SearchNormalizer.normalizeQuery("F1")) + } + + @Test + fun normalizeQuery_handles_punctuation_in_queries() { + assertEquals("24 7", SearchNormalizer.normalizeQuery("24/7")) + assertEquals("hbo", SearchNormalizer.normalizeQuery("HBO+")) + assertEquals("a e", SearchNormalizer.normalizeQuery("A&E")) + assertEquals("s w a t", SearchNormalizer.normalizeQuery("S.W.A.T")) + assertEquals("spider man", SearchNormalizer.normalizeQuery("Spider-Man")) + assertEquals("john wick chapter 4", SearchNormalizer.normalizeQuery("John Wick: Chapter 4")) + } + + @Test + fun matchesTitle_finds_short_alphanumeric_queries() { + assertTrue(SearchNormalizer.matchesTitle("Movie Channel 4K", "4k")) + assertTrue(SearchNormalizer.matchesTitle("UHD Sports Pack", "uhd")) + // "f1" must appear as a contiguous substring in the normalized title + assertTrue(SearchNormalizer.matchesTitle("F1 Racing Live", "f1")) + } + + @Test + fun matchesTitle_finds_punctuation_split_queries() { + assertTrue(SearchNormalizer.matchesTitle("24/7 Movies", "24 7")) + assertTrue(SearchNormalizer.matchesTitle("A&E HD", "a e")) + } } diff --git a/app/src/test/java/com/example/xtreamplayer/content/SearchPolicyTest.kt b/app/src/test/java/com/example/xtreamplayer/content/SearchPolicyTest.kt new file mode 100644 index 0000000..e1fbe3d --- /dev/null +++ b/app/src/test/java/com/example/xtreamplayer/content/SearchPolicyTest.kt @@ -0,0 +1,43 @@ +package com.example.xtreamplayer.content + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchPolicyTest { + + @Test + fun accepts_two_letter_queries() { + assertTrue(SearchPolicy.isSearchableNormalizedQuery("4k")) + assertTrue(SearchPolicy.isSearchableNormalizedQuery("hd")) + assertTrue(SearchPolicy.isSearchableNormalizedQuery("sd")) + assertTrue(SearchPolicy.isSearchableNormalizedQuery("f1")) + } + + @Test + fun accepts_punctuation_split_queries_by_compact_length() { + // "a&e" normalizes to "a e" — compact length is 2, should pass + assertTrue(SearchPolicy.isSearchableNormalizedQuery("a e")) + // "24/7" normalizes to "24 7" — compact length is 3, should pass + assertTrue(SearchPolicy.isSearchableNormalizedQuery("24 7")) + } + + @Test + fun rejects_single_character() { + assertFalse(SearchPolicy.isSearchableNormalizedQuery("a")) + assertFalse(SearchPolicy.isSearchableNormalizedQuery("4")) + } + + @Test + fun rejects_blank_and_empty() { + assertFalse(SearchPolicy.isSearchableNormalizedQuery("")) + assertFalse(SearchPolicy.isSearchableNormalizedQuery(" ")) + } + + @Test + fun accepts_three_plus_character_queries() { + assertTrue(SearchPolicy.isSearchableNormalizedQuery("hbo")) + assertTrue(SearchPolicy.isSearchableNormalizedQuery("uhd")) + assertTrue(SearchPolicy.isSearchableNormalizedQuery("fhd")) + } +}