From 24c005fe6c376e62a2599d9da9390aabd06f532f Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:26:22 +0530
Subject: [PATCH 1/8] fix: update iOS hardware decoder mode default to
VideoToolbox
---
.../nuvio/app/features/player/PlayerSettingsRepository.kt | 8 ++++----
iosApp/iosApp/Player/MPVPlayerBridge.swift | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index 7499364f..66020e8e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -73,7 +73,7 @@ data class PlayerSettingsUiState(
val iosToneMappingMode: IosToneMappingMode = IosToneMappingMode.Auto,
val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto,
val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto,
- val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.Auto,
+ val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox,
val iosExtendedDynamicRangeEnabled: Boolean = true,
val iosTargetColorspaceHintEnabled: Boolean = true,
val iosHdrComputePeakEnabled: Boolean = true,
@@ -131,7 +131,7 @@ object PlayerSettingsRepository {
private var iosToneMappingMode = IosToneMappingMode.Auto
private var iosTargetPrimaries = IosTargetPrimaries.Auto
private var iosTargetTransfer = IosTargetTransfer.Auto
- private var iosHardwareDecoderMode = IosHardwareDecoderMode.Auto
+ private var iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
private var iosExtendedDynamicRangeEnabled = true
private var iosTargetColorspaceHintEnabled = true
private var iosHdrComputePeakEnabled = true
@@ -194,7 +194,7 @@ object PlayerSettingsRepository {
iosToneMappingMode = IosToneMappingMode.Auto
iosTargetPrimaries = IosTargetPrimaries.Auto
iosTargetTransfer = IosTargetTransfer.Auto
- iosHardwareDecoderMode = IosHardwareDecoderMode.Auto
+ iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
iosExtendedDynamicRangeEnabled = true
iosTargetColorspaceHintEnabled = true
iosHdrComputePeakEnabled = true
@@ -317,7 +317,7 @@ object PlayerSettingsRepository {
?: IosTargetTransfer.Auto
iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode()
?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() }
- ?: IosHardwareDecoderMode.Auto
+ ?: IosHardwareDecoderMode.VideoToolbox
iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true
iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true
iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index 5216c24f..7021090a 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -498,7 +498,7 @@ final class MPVPlayerViewController: UIViewController {
metalLayer.wantsExtendedDynamicRangeContent = extendedDynamicRange
guard mpv != nil else { return }
- setStringProperty("hwdec", "videotoolbox")
+ setStringProperty("hwdec", hardwareDecoder)
setStringProperty("target-colorspace-hint", targetColorspaceHint ? "yes" : "no")
setStringProperty("tone-mapping", toneMapping)
setStringProperty("hdr-compute-peak", hdrComputePeak ? "yes" : "no")
From 406ef99cfc2a7cba567cfafdef1d667656361c51 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 21:48:35 +0530
Subject: [PATCH 2/8] feat(MPV): Spatial Audio for ios
---
MPVKit | 2 +-
.../player/PlayerSettingsStorage.android.kt | 11 ++++
.../composeResources/values/strings.xml | 7 +++
.../nuvio/app/features/player/PlayerModels.kt | 9 +++
.../player/PlayerSettingsRepository.kt | 14 +++++
.../features/player/PlayerSettingsStorage.kt | 2 +
.../features/settings/PlaybackSettingsPage.kt | 60 +++++++++++++++++--
.../app/features/player/NuvioPlayerBridge.kt | 1 +
.../app/features/player/PlayerEngine.ios.kt | 1 +
.../player/PlayerSettingsStorage.ios.kt | 11 ++++
iosApp/iosApp/Player/MPVPlayerBridge.swift | 27 ++++++++-
11 files changed, 138 insertions(+), 7 deletions(-)
diff --git a/MPVKit b/MPVKit
index 97923266..2e81303b 160000
--- a/MPVKit
+++ b/MPVKit
@@ -1 +1 @@
-Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
+Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 3dc73ce1..2a98502d 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -70,6 +70,7 @@ actual object PlayerSettingsStorage {
private const val iosTargetPrimariesKey = "ios_target_primaries"
private const val iosTargetTransferKey = "ios_target_transfer"
private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode"
+ private const val iosAudioOutputModeKey = "ios_audio_output_mode"
private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled"
private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled"
private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled"
@@ -129,6 +130,7 @@ actual object PlayerSettingsStorage {
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
+ iosAudioOutputModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
@@ -865,6 +867,13 @@ actual object PlayerSettingsStorage {
preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply()
}
+ actual fun loadIosAudioOutputMode(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosAudioOutputModeKey), null)
+
+ actual fun saveIosAudioOutputMode(mode: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosAudioOutputModeKey), mode)?.apply()
+ }
+
private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(keyBase)
@@ -994,6 +1003,7 @@ actual object PlayerSettingsStorage {
loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
+ loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) }
loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
@@ -1061,6 +1071,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
+ payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index fd8226bf..0c34f27f 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1560,6 +1560,12 @@
Let mpv target the active display color space by default.
Target primaries
Target transfer
+
+ iOS audio output
+ Audio output
+ Try AVFoundation first, then fall back to AudioUnit.
+ Experimental support for Spatial Audio and multichannel output.
+ Use the legacy AudioUnit output.
Result Management
Max results
@@ -1660,6 +1666,7 @@
Capture
Hardware decoder
+ Audio output
Target primaries
Target transfer
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt
index 94afea88..8205b616 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt
@@ -146,6 +146,15 @@ enum class IosHardwareDecoderMode(
Off("no", "Off"),
}
+enum class IosAudioOutputMode(
+ val mpvValue: String,
+ val label: String,
+) {
+ Auto("avfoundation,audiounit,", "Auto"),
+ AvFoundation("avfoundation", "AVFoundation"),
+ AudioUnit("audiounit", "AudioUnit"),
+}
+
@Composable
fun IosVideoOutputPreset.localizedLabel(): String = when (this) {
IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index 66020e8e..a4c94820 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -74,6 +74,7 @@ data class PlayerSettingsUiState(
val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto,
val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto,
val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox,
+ val iosAudioOutputMode: IosAudioOutputMode = IosAudioOutputMode.Auto,
val iosExtendedDynamicRangeEnabled: Boolean = true,
val iosTargetColorspaceHintEnabled: Boolean = true,
val iosHdrComputePeakEnabled: Boolean = true,
@@ -132,6 +133,7 @@ object PlayerSettingsRepository {
private var iosTargetPrimaries = IosTargetPrimaries.Auto
private var iosTargetTransfer = IosTargetTransfer.Auto
private var iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
+ private var iosAudioOutputMode = IosAudioOutputMode.Auto
private var iosExtendedDynamicRangeEnabled = true
private var iosTargetColorspaceHintEnabled = true
private var iosHdrComputePeakEnabled = true
@@ -195,6 +197,7 @@ object PlayerSettingsRepository {
iosTargetPrimaries = IosTargetPrimaries.Auto
iosTargetTransfer = IosTargetTransfer.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
+ iosAudioOutputMode = IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = true
iosTargetColorspaceHintEnabled = true
iosHdrComputePeakEnabled = true
@@ -318,6 +321,9 @@ object PlayerSettingsRepository {
iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode()
?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() }
?: IosHardwareDecoderMode.VideoToolbox
+ iosAudioOutputMode = PlayerSettingsStorage.loadIosAudioOutputMode()
+ ?.let { runCatching { IosAudioOutputMode.valueOf(it) }.getOrNull() }
+ ?: IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true
iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true
iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true
@@ -716,6 +722,13 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name)
}
+ fun setIosAudioOutputMode(mode: IosAudioOutputMode) {
+ ensureLoaded()
+ iosAudioOutputMode = mode
+ publish()
+ PlayerSettingsStorage.saveIosAudioOutputMode(mode.name)
+ }
+
fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) {
ensureLoaded()
iosVideoOutputPreset = IosVideoOutputPreset.Custom
@@ -853,6 +866,7 @@ object PlayerSettingsRepository {
iosTargetPrimaries = iosTargetPrimaries,
iosTargetTransfer = iosTargetTransfer,
iosHardwareDecoderMode = iosHardwareDecoderMode,
+ iosAudioOutputMode = iosAudioOutputMode,
iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled,
iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled,
iosHdrComputePeakEnabled = iosHdrComputePeakEnabled,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index e8da2d41..0196d338 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -106,6 +106,8 @@ internal expect object PlayerSettingsStorage {
fun saveIosTargetTransfer(transfer: String)
fun loadIosHardwareDecoderMode(): String?
fun saveIosHardwareDecoderMode(mode: String)
+ fun loadIosAudioOutputMode(): String?
+ fun saveIosAudioOutputMode(mode: String)
fun loadIosExtendedDynamicRangeEnabled(): Boolean?
fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean)
fun loadIosTargetColorspaceHintEnabled(): Boolean?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
index 78591459..ed5ce9da 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
@@ -59,6 +59,7 @@ import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp
import com.nuvio.app.features.player.ExternalPlayerPlatform
+import com.nuvio.app.features.player.IosAudioOutputMode
import com.nuvio.app.features.player.IosHardwareDecoderMode
import com.nuvio.app.features.player.localizedLabel
import com.nuvio.app.features.player.IosTargetPrimaries
@@ -267,6 +268,7 @@ private fun PlaybackSettingsSection(
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
+ var showIosAudioOutputDialog by remember { mutableStateOf(false) }
var showIosHardwareDecoderDialog by remember { mutableStateOf(false) }
var showIosTargetPrimariesDialog by remember { mutableStateOf(false) }
var showIosTargetTransferDialog by remember { mutableStateOf(false) }
@@ -782,6 +784,20 @@ private fun PlaybackSettingsSection(
}
if (isIos) {
+ SettingsSection(
+ title = stringResource(Res.string.settings_playback_ios_audio_output_section),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_ios_audio_output),
+ description = autoPlayPlayerSettings.iosAudioOutputMode.label,
+ isTablet = isTablet,
+ onClick = { showIosAudioOutputDialog = true },
+ )
+ }
+ }
+
SettingsSection(
title = stringResource(Res.string.settings_playback_ios_video_output),
isTablet = isTablet,
@@ -1263,6 +1279,27 @@ private fun PlaybackSettingsSection(
)
}
+ if (showIosAudioOutputDialog) {
+ IosEnumSelectionDialog(
+ title = stringResource(Res.string.settings_playback_ios_audio_output_dialog),
+ options = IosAudioOutputMode.entries,
+ selected = autoPlayPlayerSettings.iosAudioOutputMode,
+ label = { it.label },
+ description = {
+ when (it) {
+ IosAudioOutputMode.Auto -> stringResource(Res.string.settings_playback_ios_audio_output_auto_desc)
+ IosAudioOutputMode.AvFoundation -> stringResource(Res.string.settings_playback_ios_audio_output_avfoundation_desc)
+ IosAudioOutputMode.AudioUnit -> stringResource(Res.string.settings_playback_ios_audio_output_audiounit_desc)
+ }
+ },
+ onSelect = {
+ PlayerSettingsRepository.setIosAudioOutputMode(it)
+ showIosAudioOutputDialog = false
+ },
+ onDismiss = { showIosAudioOutputDialog = false },
+ )
+ }
+
if (showIosTargetPrimariesDialog) {
IosEnumSelectionDialog(
title = stringResource(Res.string.settings_playback_ios_target_primaries_dialog),
@@ -1890,6 +1927,7 @@ private fun IosEnumSelectionDialog(
options: List,
selected: T,
label: (T) -> String,
+ description: @Composable (T) -> String? = { null },
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
@@ -1918,6 +1956,7 @@ private fun IosEnumSelectionDialog(
) {
options.forEach { option ->
val isSelected = option == selected
+ val optionDescription = description(option)
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
@@ -1937,12 +1976,23 @@ private fun IosEnumSelectionDialog(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- text = label(option),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
+ Column(
modifier = Modifier.weight(1f),
- )
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ Text(
+ text = label(option),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (optionDescription != null) {
+ Text(
+ text = optionDescription,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
index 20f1f6fa..1db7a6c6 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt
@@ -30,6 +30,7 @@ interface NuvioPlayerBridge {
saturation: Int,
gamma: Int,
)
+ fun configureAudioOutput(audioOutput: String)
fun setPlaybackSpeed(speed: Float)
fun setMuted(muted: Boolean)
fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt
index 5fa4ffe7..adfa0126 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt
@@ -309,6 +309,7 @@ actual fun PlatformPlayerSurface(
}
private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) {
+ configureAudioOutput(audioOutput = settings.iosAudioOutputMode.mpvValue)
configureVideoOutput(
hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue,
targetColorspaceHint = settings.iosTargetColorspaceHintEnabled,
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index d1e7a6ef..6da10aff 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -68,6 +68,7 @@ actual object PlayerSettingsStorage {
private const val iosTargetPrimariesKey = "ios_target_primaries"
private const val iosTargetTransferKey = "ios_target_transfer"
private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode"
+ private const val iosAudioOutputModeKey = "ios_audio_output_mode"
private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled"
private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled"
private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled"
@@ -127,6 +128,7 @@ actual object PlayerSettingsStorage {
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
+ iosAudioOutputModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
@@ -735,6 +737,13 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosHardwareDecoderModeKey))
}
+ actual fun loadIosAudioOutputMode(): String? =
+ NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosAudioOutputModeKey))
+
+ actual fun saveIosAudioOutputMode(mode: String) {
+ NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosAudioOutputModeKey))
+ }
+
actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? =
loadBoolean(iosExtendedDynamicRangeEnabledKey)
@@ -844,6 +853,7 @@ actual object PlayerSettingsStorage {
loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
+ loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) }
loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
@@ -910,6 +920,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
+ payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index 7021090a..37111147 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -59,6 +59,9 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
gamma: Int(gamma)
)
}
+ func configureAudioOutput(audioOutput: String) {
+ playerVC?.configureAudioOutput(audioOutput: audioOutput)
+ }
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setMuted(muted: Bool) { playerVC?.setMuted(muted) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
@@ -192,6 +195,8 @@ private struct PendingLoadRequest {
final class MPVPlayerViewController: UIViewController {
+ private static let defaultAudioOutput = "avfoundation,audiounit,"
+
private let errorStateLock = NSLock()
private var metalLayer = MetalLayer()
private var lastAppliedDrawableSize: CGSize = .zero
@@ -318,7 +323,8 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
- checkError(mpv_set_option_string(mpv, "audio-channels", "stereo"))
+ checkError(mpv_set_option_string(mpv, "ao", Self.defaultAudioOutput))
+ checkError(mpv_set_option_string(mpv, "audio-channels", "auto"))
checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes"))
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
@@ -512,6 +518,11 @@ final class MPVPlayerViewController: UIViewController {
setVideoEqualizer("gamma", gamma)
}
+ func configureAudioOutput(audioOutput: String) {
+ guard mpv != nil else { return }
+ setStringProperty("ao", audioOutput)
+ }
+
func setSpeed(_ speed: Float) {
guard mpv != nil else { return }
var s = Double(speed)
@@ -853,6 +864,7 @@ final class MPVPlayerViewController: UIViewController {
self.clearPlaybackError()
self.isPlayerLoading = false
self.updateState()
+ self.logCurrentAudioOutput()
}
case MPV_EVENT_END_FILE:
if let data = eventPtr.pointee.data {
@@ -943,6 +955,19 @@ final class MPVPlayerViewController: UIViewController {
return Int(data)
}
+ private func logCurrentAudioOutput() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+ guard let self, self.mpv != nil else { return }
+ let currentAo = self.getString("current-ao") ?? "unknown"
+ let channels = self.getString("audio-out-params/hr-channels")
+ ?? self.getString("audio-params/hr-channels")
+ ?? "unknown"
+ let channelCount = self.getInt("audio-out-params/channel-count")
+ let codec = self.getString("audio-codec-name") ?? "unknown"
+ print("[MPV] Audio output: ao=\(currentAo), channels=\(channels), channelCount=\(channelCount), codec=\(codec)")
+ }
+ }
+
private func checkError(_ status: CInt) {
if status < 0 {
print("[MPV] API error: \(String(cString: mpv_error_string(status)))")
From 939bca4848dfa11856c82ec3fb944bdd2f5f16e9 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:04:22 +0530
Subject: [PATCH 3/8] ref(player) : into smaller chunks
---
.../player/PlayerNextEpisodeAutoPlay.kt | 280 ++
.../features/player/PlayerPlaybackOverlays.kt | 165 +
.../nuvio/app/features/player/PlayerScreen.kt | 3128 +----------------
.../app/features/player/PlayerScreenArgs.kt | 38 +
.../features/player/PlayerScreenContent.kt | 146 +
.../features/player/PlayerScreenModalHosts.kt | 236 ++
.../player/PlayerScreenRuntimeEffects.kt | 471 +++
.../PlayerScreenRuntimeGestureActions.kt | 310 ++
.../PlayerScreenRuntimePlaybackActions.kt | 208 ++
.../PlayerScreenRuntimeSourceActions.kt | 424 +++
.../player/PlayerScreenRuntimeState.kt | 192 +
.../PlayerScreenRuntimeSubtitleActions.kt | 63 +
.../player/PlayerScreenRuntimeTrackActions.kt | 217 ++
.../features/player/PlayerScreenRuntimeUi.kt | 523 +++
.../features/player/PlayerScreenSupport.kt | 59 +
.../features/player/PlayerSurfaceGestures.kt | 196 ++
.../features/player/PlayerTrackSelection.kt | 167 +
vendor/TorrServer | 1 +
18 files changed, 3730 insertions(+), 3094 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt
create mode 160000 vendor/TorrServer
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt
new file mode 100644
index 00000000..535fc341
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt
@@ -0,0 +1,280 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.enabledAddons
+import com.nuvio.app.features.debrid.DebridSettingsRepository
+import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.downloads.DownloadItem
+import com.nuvio.app.features.downloads.DownloadsRepository
+import com.nuvio.app.features.player.skip.NextEpisodeInfo
+import com.nuvio.app.features.streams.StreamAutoPlayMode
+import com.nuvio.app.features.streams.StreamAutoPlaySelector
+import com.nuvio.app.features.streams.StreamAutoPlaySource
+import com.nuvio.app.features.streams.StreamItem
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay(
+ previousJob: Job?,
+ nextEpisodeInfo: NextEpisodeInfo?,
+ allEpisodes: List,
+ parentMetaId: String,
+ parentMetaType: String,
+ contentType: String?,
+ settings: PlayerSettingsUiState,
+ currentStreamBingeGroup: String?,
+ onDownloadedEpisodeSelected: (DownloadItem, MetaVideo) -> Unit,
+ onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit,
+ onManualSelectionRequired: (MetaVideo) -> Unit,
+ onSearchingChanged: (Boolean) -> Unit,
+ onSourceNameChanged: (String?) -> Unit,
+ onCountdownChanged: (Int?) -> Unit,
+ onNextEpisodeCardVisibleChanged: (Boolean) -> Unit,
+): Job? {
+ val nextVideoId = nextEpisodeInfo?.videoId ?: return null
+ val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return null
+ if (nextEpisodeInfo.hasAired != true) return null
+
+ val downloadedNextEpisode = DownloadsRepository.findPlayableDownload(
+ parentMetaId = parentMetaId,
+ seasonNumber = nextVideo.season,
+ episodeNumber = nextVideo.episode,
+ videoId = nextVideo.id,
+ )
+ if (downloadedNextEpisode != null) {
+ onDownloadedEpisodeSelected(downloadedNextEpisode, nextVideo)
+ return null
+ }
+
+ previousJob?.cancel()
+ onSearchingChanged(true)
+ onSourceNameChanged(null)
+ onCountdownChanged(null)
+
+ val type = contentType ?: parentMetaType
+ val shouldAutoSelectInManualMode =
+ settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL &&
+ (
+ settings.streamAutoPlayNextEpisodeEnabled ||
+ settings.streamAutoPlayPreferBingeGroup
+ )
+
+ val bingeGroupOnlyManualMode =
+ shouldAutoSelectInManualMode &&
+ !settings.streamAutoPlayNextEpisodeEnabled &&
+ settings.streamAutoPlayPreferBingeGroup
+
+ val effectiveMode = if (shouldAutoSelectInManualMode) {
+ StreamAutoPlayMode.FIRST_STREAM
+ } else {
+ settings.streamAutoPlayMode
+ }
+ val effectiveSource = if (shouldAutoSelectInManualMode) {
+ StreamAutoPlaySource.ALL_SOURCES
+ } else {
+ settings.streamAutoPlaySource
+ }
+ val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) {
+ emptySet()
+ } else {
+ settings.streamAutoPlaySelectedAddons
+ }
+ val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) {
+ emptySet()
+ } else {
+ settings.streamAutoPlaySelectedPlugins
+ }
+ val effectiveRegex = if (shouldAutoSelectInManualMode) {
+ ""
+ } else {
+ settings.streamAutoPlayRegex
+ }
+ val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
+ currentStreamBingeGroup
+ } else {
+ null
+ }
+
+ return launch {
+ PlayerStreamsRepository.loadEpisodeStreams(
+ type = type,
+ videoId = nextVideo.id,
+ season = nextVideo.season,
+ episode = nextVideo.episode,
+ )
+
+ val installedAddonNames = AddonRepository.uiState.value.addons
+ .enabledAddons()
+ .map { it.displayTitle }
+ .toSet()
+ val debridSettings = DebridSettingsRepository.snapshot()
+
+ val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
+ var autoSelectTriggered = false
+ var timeoutElapsed = false
+ var selectedStream: StreamItem? = null
+ val autoSelectSettled = CompletableDeferred()
+
+ fun settleAutoSelect() {
+ if (!autoSelectSettled.isCompleted) {
+ autoSelectSettled.complete(Unit)
+ }
+ }
+
+ fun selectStream(stream: StreamItem) {
+ autoSelectTriggered = true
+ selectedStream = stream
+ settleAutoSelect()
+ }
+
+ fun finishWithoutSelection() {
+ autoSelectTriggered = true
+ settleAutoSelect()
+ }
+
+ fun trySelectStream(streams: List): StreamItem? =
+ StreamAutoPlaySelector.selectAutoPlayStream(
+ streams = streams,
+ mode = effectiveMode,
+ regexPattern = effectiveRegex,
+ source = effectiveSource,
+ installedAddonNames = installedAddonNames,
+ selectedAddons = effectiveSelectedAddons,
+ selectedPlugins = effectiveSelectedPlugins,
+ preferredBingeGroup = preferredBingeGroup,
+ preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
+ bingeGroupOnly = bingeGroupOnlyManualMode,
+ debridEnabled = debridSettings.canResolvePlayableLinks,
+ activeResolverProviderId = debridSettings.activeResolverProviderId,
+ )
+
+ fun tryBingeGroupOnly(streams: List): StreamItem? {
+ if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null
+ return StreamAutoPlaySelector.selectAutoPlayStream(
+ streams = streams,
+ mode = effectiveMode,
+ regexPattern = effectiveRegex,
+ source = effectiveSource,
+ installedAddonNames = installedAddonNames,
+ selectedAddons = effectiveSelectedAddons,
+ selectedPlugins = effectiveSelectedPlugins,
+ preferredBingeGroup = preferredBingeGroup,
+ preferBingeGroupInSelection = true,
+ bingeGroupOnly = true,
+ debridEnabled = debridSettings.canResolvePlayableLinks,
+ activeResolverProviderId = debridSettings.activeResolverProviderId,
+ )
+ }
+
+ val innerJob = launch {
+ PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
+ if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
+
+ val allStreams = state.groups.flatMap { it.streams }
+
+ if (autoSelectTriggered) {
+ // Already resolved.
+ } else if (timeoutElapsed) {
+ if (allStreams.isNotEmpty()) {
+ val candidate = trySelectStream(allStreams)
+ if (candidate != null) {
+ selectStream(candidate)
+ }
+ }
+ } else if (allStreams.isNotEmpty()) {
+ val earlyMatch = tryBingeGroupOnly(allStreams)
+ if (earlyMatch != null) {
+ selectStream(earlyMatch)
+ }
+ }
+
+ if (!autoSelectTriggered && !state.isAnyLoading) {
+ if (allStreams.isNotEmpty()) {
+ val candidate = trySelectStream(allStreams)
+ if (candidate != null) {
+ selectStream(candidate)
+ }
+ }
+ if (!autoSelectTriggered) {
+ finishWithoutSelection()
+ }
+ return@collectLatest
+ }
+
+ if (autoSelectTriggered) return@collectLatest
+ }
+ }
+
+ val timeoutMs = timeoutSeconds * 1_000L
+ val isBoundedTimeout = timeoutSeconds in 1..30
+
+ if (isBoundedTimeout) {
+ delay(timeoutMs)
+ timeoutElapsed = true
+ if (!autoSelectTriggered) {
+ val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
+ if (allStreams.isNotEmpty()) {
+ val candidate = trySelectStream(allStreams)
+ if (candidate != null) {
+ selectStream(candidate)
+ }
+ }
+ }
+ if (selectedStream != null) {
+ innerJob.cancel()
+ } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
+ innerJob.cancel()
+ finishWithoutSelection()
+ } else {
+ val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() }
+ innerJob.cancel()
+ if (completed == null && !autoSelectTriggered) {
+ val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
+ if (allStreams.isNotEmpty()) {
+ selectedStream = trySelectStream(allStreams)
+ }
+ finishWithoutSelection()
+ }
+ }
+ } else {
+ timeoutElapsed = true
+ if (!autoSelectTriggered) {
+ val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
+ if (allStreams.isNotEmpty()) {
+ trySelectStream(allStreams)?.let(::selectStream)
+ }
+ }
+ val completed = withTimeoutOrNull(NEXT_EPISODE_HARD_TIMEOUT_MS) { autoSelectSettled.await() }
+ innerJob.cancel()
+ if (completed == null && !autoSelectTriggered) {
+ val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
+ if (allStreams.isNotEmpty()) {
+ selectedStream = trySelectStream(allStreams)
+ }
+ finishWithoutSelection()
+ }
+ }
+
+ onSearchingChanged(false)
+ val selected = selectedStream
+ if (selected != null) {
+ onSourceNameChanged(selected.addonName)
+ for (i in 3 downTo 1) {
+ onCountdownChanged(i)
+ delay(1000)
+ }
+ onEpisodeStreamSelected(selected, nextVideo)
+ onNextEpisodeCardVisibleChanged(false)
+ onCountdownChanged(null)
+ onSourceNameChanged(null)
+ } else {
+ onManualSelectionRequired(nextVideo)
+ onNextEpisodeCardVisibleChanged(false)
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt
new file mode 100644
index 00000000..ea0a925f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt
@@ -0,0 +1,165 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.features.p2p.P2pLoadingStatus
+import com.nuvio.app.features.player.skip.NextEpisodeCard
+import com.nuvio.app.features.player.skip.NextEpisodeInfo
+import com.nuvio.app.features.player.skip.SkipIntroButton
+import com.nuvio.app.features.player.skip.SkipInterval
+
+@Composable
+internal fun BoxScope.PlayerPlaybackOverlays(
+ playerControlsLocked: Boolean,
+ lockedOverlayVisible: Boolean,
+ playbackSnapshot: PlayerPlaybackSnapshot,
+ displayedPositionMs: Long,
+ metrics: PlayerLayoutMetrics,
+ horizontalSafePadding: Dp,
+ onUnlock: () -> Unit,
+ showOpeningOverlay: Boolean,
+ backdropArtwork: String?,
+ logo: String?,
+ title: String,
+ onBackWithProgress: () -> Unit,
+ p2pInitialLoadingMessage: String?,
+ p2pInitialLoadingProgress: Float?,
+ showP2pRebufferStats: Boolean,
+ p2pRebufferMessage: String?,
+ p2pRebufferProgress: Float?,
+ currentGestureFeedback: GestureFeedbackState?,
+ renderedGestureFeedback: GestureFeedbackState?,
+ initialLoadCompleted: Boolean,
+ pausedOverlayVisible: Boolean,
+ activeSkipInterval: SkipInterval?,
+ skipIntervalDismissed: Boolean,
+ controlsVisible: Boolean,
+ onSkipInterval: (SkipInterval) -> Unit,
+ onDismissSkipInterval: () -> Unit,
+ sliderEdgePadding: Dp,
+ overlayBottomPadding: Dp,
+ isSeries: Boolean,
+ nextEpisodeInfo: NextEpisodeInfo?,
+ showNextEpisodeCard: Boolean,
+ nextEpisodeAutoPlaySearching: Boolean,
+ nextEpisodeAutoPlaySourceName: String?,
+ nextEpisodeAutoPlayCountdown: Int?,
+ onPlayNextEpisode: () -> Unit,
+ onDismissNextEpisode: () -> Unit,
+ errorMessage: String?,
+ onDismissError: () -> Unit,
+) {
+ AnimatedVisibility(
+ visible = playerControlsLocked && lockedOverlayVisible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ LockedPlayerOverlay(
+ playbackSnapshot = playbackSnapshot,
+ displayedPositionMs = displayedPositionMs,
+ metrics = metrics,
+ horizontalSafePadding = horizontalSafePadding,
+ onUnlock = onUnlock,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ AnimatedVisibility(
+ visible = showOpeningOverlay,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ OpeningOverlay(
+ artwork = backdropArtwork,
+ logo = logo,
+ title = title,
+ onBack = onBackWithProgress,
+ horizontalSafePadding = horizontalSafePadding,
+ modifier = Modifier.fillMaxSize(),
+ message = p2pInitialLoadingMessage,
+ progress = p2pInitialLoadingProgress,
+ )
+ }
+
+ P2pLoadingStatus(
+ visible = showP2pRebufferStats && errorMessage == null,
+ message = p2pRebufferMessage,
+ progress = p2pRebufferProgress,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(top = 58.dp),
+ )
+
+ AnimatedVisibility(
+ visible = currentGestureFeedback != null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ renderedGestureFeedback?.let { feedback ->
+ GestureFeedbackPill(
+ feedback = feedback,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
+ .padding(horizontal = horizontalSafePadding)
+ .padding(top = 40.dp),
+ )
+ }
+ }
+ }
+
+ if (!playerControlsLocked) {
+ SkipIntroButton(
+ interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval,
+ dismissed = skipIntervalDismissed,
+ controlsVisible = controlsVisible,
+ onSkip = {
+ activeSkipInterval?.let(onSkipInterval)
+ },
+ onDismiss = onDismissSkipInterval,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
+ )
+ }
+
+ if (isSeries && !playerControlsLocked) {
+ NextEpisodeCard(
+ nextEpisode = nextEpisodeInfo,
+ visible = showNextEpisodeCard,
+ isAutoPlaySearching = nextEpisodeAutoPlaySearching,
+ autoPlaySourceName = nextEpisodeAutoPlaySourceName,
+ autoPlayCountdownSec = nextEpisodeAutoPlayCountdown,
+ onPlayNext = onPlayNextEpisode,
+ onDismiss = onDismissNextEpisode,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = sliderEdgePadding, bottom = overlayBottomPadding),
+ )
+ }
+
+ if (errorMessage != null) {
+ ErrorModal(
+ message = errorMessage,
+ onDismiss = onDismissError,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index e00f53fc..368d2e5a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -1,152 +1,7 @@
package com.nuvio.app.features.player
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.awaitEachGesture
-import androidx.compose.foundation.gestures.awaitFirstDown
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.WindowInsetsSides
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.only
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeContent
-import androidx.compose.foundation.layout.windowInsetsPadding
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.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.nuvio.app.core.ui.NuvioToastController
-import com.nuvio.app.features.debrid.DebridSettingsRepository
-import com.nuvio.app.features.debrid.DirectDebridPlayableResult
-import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
-import com.nuvio.app.features.debrid.toastMessage
-import com.nuvio.app.features.addons.AddonRepository
-import com.nuvio.app.features.addons.AddonResource
-import com.nuvio.app.features.addons.ManagedAddon
-import com.nuvio.app.features.addons.enabledAddons
-import com.nuvio.app.features.addons.httpGetTextWithHeaders
-import com.nuvio.app.features.details.MetaDetailsRepository
-import com.nuvio.app.features.details.MetaScreenSettingsRepository
-import com.nuvio.app.features.details.MetaVideo
-import com.nuvio.app.features.downloads.DownloadItem
-import com.nuvio.app.features.downloads.DownloadsRepository
-import com.nuvio.app.features.p2p.P2pConsentDialog
-import com.nuvio.app.features.p2p.P2pLoadingStatus
-import com.nuvio.app.features.p2p.P2pSettingsRepository
-import com.nuvio.app.features.p2p.P2pStreamRequest
-import com.nuvio.app.features.p2p.P2pStreamingEngine
-import com.nuvio.app.features.p2p.P2pStreamingState
-import com.nuvio.app.features.p2p.formatP2pMegabytes
-import com.nuvio.app.features.p2p.formatP2pSpeed
-import com.nuvio.app.features.player.skip.NextEpisodeCard
-import com.nuvio.app.features.player.skip.NextEpisodeInfo
-import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
-import com.nuvio.app.features.player.skip.SkipIntroButton
-import com.nuvio.app.features.player.skip.SkipIntroRepository
-import com.nuvio.app.features.player.skip.SkipInterval
-import com.nuvio.app.features.streams.BingeGroupCacheRepository
-import com.nuvio.app.features.streams.StreamAutoPlayMode
-import com.nuvio.app.features.streams.StreamAutoPlaySelector
-import com.nuvio.app.features.streams.StreamAutoPlaySource
-import com.nuvio.app.features.streams.StreamItem
-import com.nuvio.app.features.streams.StreamLinkCacheRepository
-import com.nuvio.app.features.streams.StreamsUiState
-import com.nuvio.app.features.tmdb.TmdbService
-import com.nuvio.app.features.trakt.TraktScrobbleItem
-import com.nuvio.app.features.trakt.TraktScrobbleRepository
-import com.nuvio.app.features.watched.WatchedRepository
-import com.nuvio.app.features.watchprogress.WatchProgressClock
-import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
-import com.nuvio.app.features.watchprogress.WatchProgressRepository
-import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
-import com.nuvio.app.isIos
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeoutOrNull
-import nuvio.composeapp.generated.resources.*
-import org.jetbrains.compose.resources.getString
-import org.jetbrains.compose.resources.stringResource
-import kotlin.math.abs
-import kotlin.math.roundToLong
-import kotlin.math.roundToInt
-
-private const val PlaybackProgressPersistIntervalMs = 60_000L
-private const val PlayerDoubleTapSeekStepMs = 10_000L
-private const val PlayerDoubleTapSeekResetDelayMs = 800L
-private const val PlayerLockedOverlayDurationMs = 2_000L
-private const val PlayerLeftGestureBoundary = 0.4f
-private const val PlayerRightGestureBoundary = 0.6f
-private const val PlayerVerticalGestureSensitivity = 1f
-private const val PlayerSeekProgressSyncDebounceMs = 700L
-private const val P2pInitialPreloadTargetBytes = 5_242_880L
-/** Hard ceiling for next-episode stream search to prevent hanging forever. */
-private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
-private val PlayerSliderOverlayGap = 12.dp
-private val PlayerTimeRowHeight = 36.dp
-private val PlayerActionRowHeight = 50.dp
-
-private fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) =
- metrics.sliderBottomOffset +
- metrics.sliderTouchHeight +
- PlayerTimeRowHeight +
- PlayerActionRowHeight +
- PlayerSliderOverlayGap
-
-private enum class PlayerSideGesture {
- Brightness,
- Volume,
-}
-
-private enum class PlayerSeekDirection {
- Backward,
- Forward,
-}
-
-private enum class PlayerGestureMode {
- HorizontalSeek,
- Brightness,
- Volume,
-}
-
-private data class PlayerAccumulatedSeekState(
- val direction: PlayerSeekDirection,
- val baselinePositionMs: Long,
- val amountMs: Long,
-)
-
-private data class PendingPlayerP2pSwitch(
- val stream: StreamItem,
- val episode: MetaVideo?,
- val isAutoPlay: Boolean,
-)
@Composable
fun PlayerScreen(
@@ -183,2955 +38,40 @@ fun PlayerScreen(
initialPositionMs: Long = 0L,
initialProgressFraction: Float? = null,
) {
- LockPlayerToLandscape()
- val playerSettingsUiState by remember {
- PlayerSettingsRepository.ensureLoaded()
- PlayerSettingsRepository.uiState
- }.collectAsStateWithLifecycle()
- val p2pSettingsUiState by remember {
- P2pSettingsRepository.ensureLoaded()
- P2pSettingsRepository.uiState
- }.collectAsStateWithLifecycle()
- val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle()
- val metaScreenSettingsUiState by remember {
- MetaScreenSettingsRepository.ensureLoaded()
- MetaScreenSettingsRepository.uiState
- }.collectAsStateWithLifecycle()
- val watchedUiState by remember {
- WatchedRepository.ensureLoaded()
- WatchedRepository.uiState
- }.collectAsStateWithLifecycle()
- val watchProgressUiState by remember {
- WatchProgressRepository.ensureLoaded()
- WatchProgressRepository.uiState
- }.collectAsStateWithLifecycle()
-
- BoxWithConstraints(
- modifier = modifier
- .fillMaxSize()
- .background(Color.Black),
- ) {
- val horizontalSafePadding = playerHorizontalSafePadding()
- val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) }
- val sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding
- val overlayBottomPadding = sliderOverlayBottomPadding(metrics)
- val scope = rememberCoroutineScope()
- val hapticFeedback = LocalHapticFeedback.current
- val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit)
- val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill)
- val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom)
- val downloadedLabel = stringResource(Res.string.compose_player_downloaded)
- val airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
- val tbaLabel = stringResource(Res.string.compose_player_tba)
- val genericUnknownLabel = stringResource(Res.string.generic_unknown)
- val parentalGuideLabels = ParentalGuideLabels(
- nudity = stringResource(Res.string.parental_nudity),
- violence = stringResource(Res.string.parental_violence),
- profanity = stringResource(Res.string.parental_profanity),
- alcohol = stringResource(Res.string.parental_alcohol),
- frightening = stringResource(Res.string.parental_frightening),
- severe = stringResource(Res.string.parental_severity_severe),
- moderate = stringResource(Res.string.parental_severity_moderate),
- mild = stringResource(Res.string.parental_severity_mild),
- )
- val gestureController = rememberPlayerGestureController()
- var controlsVisible by rememberSaveable { mutableStateOf(true) }
- var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
- // Active playback state (mutable to support source/episode switching)
- var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
- var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
- var activeSourceHeaders by remember(sourceUrl, sourceHeaders) {
- mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
- }
- var activeSourceResponseHeaders by remember(sourceUrl, sourceResponseHeaders) {
- mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
- }
- var activeTorrentInfoHash by rememberSaveable { mutableStateOf(torrentInfoHash) }
- var activeTorrentFileIdx by rememberSaveable { mutableStateOf(torrentFileIdx) }
- var activeTorrentFilename by rememberSaveable { mutableStateOf(torrentFilename) }
- var activeTorrentMagnetUri by rememberSaveable { mutableStateOf(torrentMagnetUri) }
- var activeTorrentTrackers by remember { mutableStateOf(torrentTrackers) }
- var p2pResolvedSourceUrl by remember { mutableStateOf(null) }
- val activePlaybackIdentity = activeTorrentInfoHash
- ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" }
- ?: activeSourceUrl
- var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) }
- var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) }
- var activeProviderName by rememberSaveable { mutableStateOf(providerName) }
- var activeProviderAddonId by rememberSaveable { mutableStateOf(providerAddonId) }
- var currentStreamBingeGroup by rememberSaveable { mutableStateOf(initialBingeGroup) }
- var activeSeasonNumber by rememberSaveable { mutableStateOf(seasonNumber) }
- var activeEpisodeNumber by rememberSaveable { mutableStateOf(episodeNumber) }
- var activeEpisodeTitle by rememberSaveable { mutableStateOf(episodeTitle) }
- var activeEpisodeThumbnail by rememberSaveable { mutableStateOf(episodeThumbnail) }
- var activeVideoId by rememberSaveable { mutableStateOf(videoId) }
- var activeInitialPositionMs by rememberSaveable { mutableStateOf(initialPositionMs) }
- var activeInitialProgressFraction by rememberSaveable { mutableStateOf(initialProgressFraction) }
- var shouldPlay by rememberSaveable(activePlaybackIdentity) { mutableStateOf(true) }
- var resizeMode by rememberSaveable(playerSettingsUiState.resizeMode) {
- mutableStateOf(playerSettingsUiState.resizeMode)
- }
- var layoutSize by remember { mutableStateOf(IntSize.Zero) }
- var playbackSnapshot by remember { mutableStateOf(PlayerPlaybackSnapshot()) }
- var playerController by remember { mutableStateOf(null) }
- var playerControllerSourceUrl by remember { mutableStateOf(null) }
- var errorMessage by remember { mutableStateOf(null) }
- val keepScreenAwake = errorMessage == null &&
- (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading))
- EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake)
- var isScrubbingTimeline by remember { mutableStateOf(false) }
- var scrubbingPositionMs by remember { mutableStateOf(null) }
- var pausedOverlayVisible by remember { mutableStateOf(false) }
- var gestureFeedback by remember { mutableStateOf(null) }
- var liveGestureFeedback by remember { mutableStateOf(null) }
- var renderedGestureFeedback by remember { mutableStateOf(null) }
- var lockedOverlayVisible by remember { mutableStateOf(false) }
- var gestureMessageJob by remember { mutableStateOf(null) }
- var accumulatedSeekResetJob by remember { mutableStateOf(null) }
- var seekProgressSyncJob by remember { mutableStateOf(null) }
- var accumulatedSeekState by remember { mutableStateOf(null) }
- var initialLoadCompleted by remember(activePlaybackIdentity) { mutableStateOf(false) }
- var speedBoostRestoreSpeed by remember(activePlaybackIdentity) { mutableStateOf(null) }
- var isHoldToSpeedGestureActive by remember(activePlaybackIdentity) { mutableStateOf(false) }
- var initialSeekApplied by remember(
- activePlaybackIdentity,
- activeInitialPositionMs,
- activeInitialProgressFraction,
- ) {
- val initialProgressFraction = activeInitialProgressFraction
- mutableStateOf(
- activeInitialPositionMs <= 0L &&
- (initialProgressFraction == null || initialProgressFraction <= 0f),
- )
- }
- var lastProgressPersistEpochMs by remember(activePlaybackIdentity) { mutableStateOf(0L) }
- var previousIsPlaying by remember(activePlaybackIdentity) { mutableStateOf(false) }
- var hasRequestedScrobbleStartForCurrentItem by remember(
- activePlaybackIdentity,
- activeVideoId,
- activeSeasonNumber,
- activeEpisodeNumber,
- ) { mutableStateOf(false) }
- var scrobbleStartRequestGeneration by remember(
- activePlaybackIdentity,
- activeVideoId,
- activeSeasonNumber,
- activeEpisodeNumber,
- ) { mutableStateOf(0L) }
- var pendingScrobbleStartAfterSeek by remember(
- activePlaybackIdentity,
- activeVideoId,
- activeSeasonNumber,
- activeEpisodeNumber,
- ) { mutableStateOf(false) }
- var hasSentCompletionScrobbleForCurrentItem by remember(
- activeVideoId,
- activeSeasonNumber,
- activeEpisodeNumber,
- ) { mutableStateOf(false) }
- var currentTraktScrobbleItem by remember(
- activePlaybackIdentity,
- activeVideoId,
- activeSeasonNumber,
- activeEpisodeNumber,
- ) { mutableStateOf(null) }
- val backdropArtwork = background ?: poster
- val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
- val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
- val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
- val isP2pPlaybackActive = activeTorrentInfoHash != null
- val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming
- val p2pPeerInfo = p2pStats?.let { stats ->
- stringResource(Res.string.player_torrent_peer_info, stats.seeds, stats.peers)
- }
- val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) }
- val p2pInitialLoadingMessage = when {
- !isP2pPlaybackActive || initialLoadCompleted -> null
- p2pStreamingState is P2pStreamingState.Connecting -> {
- stringResource(Res.string.player_torrent_connecting_peers)
- }
- p2pStats != null -> {
- if (p2pSettingsUiState.hideTorrentStats) {
- null
- } else {
- stringResource(
- Res.string.player_torrent_buffered_status,
- formatP2pMegabytes(p2pStats.preloadedBytes),
- p2pPeerInfo.orEmpty(),
- p2pDownloadSpeed.orEmpty(),
- )
- }
- }
- else -> stringResource(Res.string.player_torrent_starting_engine)
- }
- val p2pInitialLoadingProgress = when {
- !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null
- else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f)
- }
- val showP2pRebufferStats = isP2pPlaybackActive &&
- initialLoadCompleted &&
- playbackSnapshot.isLoading &&
- p2pStats != null &&
- !p2pSettingsUiState.hideTorrentStats
- val p2pRebufferMessage = when {
- !showP2pRebufferStats -> null
- else -> {
- val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L)
- .coerceAtLeast(0L)
- val peerInfo = p2pPeerInfo.orEmpty()
- val speed = p2pDownloadSpeed.orEmpty()
- "${bufferedSeconds}s buffered · $peerInfo · $speed"
- }
- }
- val p2pRebufferProgress = when {
- !showP2pRebufferStats -> null
- else -> {
- val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f)
- .coerceAtLeast(0f)
- (bufferedSeconds / 10f).coerceIn(0f, 1f)
- }
- }
-
- LaunchedEffect(currentGestureFeedback) {
- if (currentGestureFeedback != null) {
- renderedGestureFeedback = currentGestureFeedback
- }
- }
-
- // Sources & Episodes Panel state
- var showSourcesPanel by remember { mutableStateOf(false) }
- var showEpisodesPanel by remember { mutableStateOf(false) }
- var showSubmitIntroModal by remember { mutableStateOf(false) }
- var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") }
- var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") }
- var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") }
- var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
- val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
- val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
- val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
- var playerMetaVideos by remember(parentMetaType, parentMetaId) {
- mutableStateOf(MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList())
- }
- val allEpisodes = remember(playerMetaVideos) { playerMetaVideos }
- val isSeries = parentMetaType == "series"
-
- // Skip intro/outro/recap state
- var skipIntervals by remember { mutableStateOf>(emptyList()) }
- var activeSkipInterval by remember { mutableStateOf(null) }
- var skipIntervalDismissed by remember { mutableStateOf(false) }
-
- // Parental guide overlay state
- var parentalWarnings by remember { mutableStateOf>(emptyList()) }
- var showParentalGuide by remember { mutableStateOf(false) }
- var parentalGuideHasShown by remember { mutableStateOf(false) }
- var playbackStartedForParentalGuide by remember { mutableStateOf(false) }
-
- // Next episode state
- var nextEpisodeInfo by remember { mutableStateOf(null) }
- var showNextEpisodeCard by remember { mutableStateOf(false) }
- var nextEpisodeAutoPlaySearching by remember { mutableStateOf(false) }
- var nextEpisodeAutoPlaySourceName by remember { mutableStateOf(null) }
- var nextEpisodeAutoPlayCountdown by remember { mutableStateOf(null) }
- var nextEpisodeAutoPlayJob by remember { mutableStateOf(null) }
- var pendingP2pSwitch by remember { mutableStateOf(null) }
-
- LaunchedEffect(parentMetaType, parentMetaId) {
- playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList()
- if (playerMetaVideos.isEmpty()) {
- playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
- }
- }
-
- LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) {
- val currentMeta = metaUiState.meta ?: return@LaunchedEffect
- if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) {
- playerMetaVideos = currentMeta.videos
- }
- }
-
- // Persist binge group per content so subsequent episode plays
- // (from CW, Details, or next-episode) can reuse the same source group.
- LaunchedEffect(currentStreamBingeGroup, parentMetaId) {
- val bg = currentStreamBingeGroup
- if (bg != null && parentMetaId.isNotBlank()) {
- BingeGroupCacheRepository.save(parentMetaId, bg)
- }
- }
-
- ManagePlayerPictureInPicture(
- isPlaying = playbackSnapshot.isPlaying,
- playerSize = layoutSize,
- )
-
- val playbackSession = remember(
- contentType,
- parentMetaId,
- parentMetaType,
- activeVideoId,
- title,
- logo,
- poster,
- background,
- activeSeasonNumber,
- activeEpisodeNumber,
- activeEpisodeTitle,
- activeEpisodeThumbnail,
- activeProviderName,
- activeProviderAddonId,
- activeStreamTitle,
- activeStreamSubtitle,
- pauseDescription,
- activeSourceUrl,
- activeSourceAudioUrl,
- ) {
- WatchProgressPlaybackSession(
- contentType = contentType ?: parentMetaType,
- parentMetaId = parentMetaId,
- parentMetaType = parentMetaType,
- videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId(
- parentMetaId = parentMetaId,
- seasonNumber = activeSeasonNumber,
- episodeNumber = activeEpisodeNumber,
- fallbackVideoId = activeVideoId,
- ),
- title = title,
- logo = logo,
- poster = poster,
- background = background,
- seasonNumber = activeSeasonNumber,
- episodeNumber = activeEpisodeNumber,
- episodeTitle = activeEpisodeTitle,
- episodeThumbnail = activeEpisodeThumbnail,
- providerName = activeProviderName,
- providerAddonId = activeProviderAddonId,
- lastStreamTitle = activeStreamTitle,
- lastStreamSubtitle = activeStreamSubtitle,
- pauseDescription = pauseDescription,
- lastSourceUrl = activeSourceUrl,
- )
- }
-
- fun currentPlaybackProgressPercent(snapshot: PlayerPlaybackSnapshot = playbackSnapshot): Float {
- val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f
- return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f)
- .coerceIn(0f, 100f)
- }
-
- suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
- contentType = contentType ?: parentMetaType,
- parentMetaId = parentMetaId,
- videoId = activeVideoId,
+ PlayerScreenContent(
+ PlayerScreenArgs(
title = title,
- seasonNumber = activeSeasonNumber,
- episodeNumber = activeEpisodeNumber,
- episodeTitle = activeEpisodeTitle,
+ sourceUrl = sourceUrl,
+ sourceAudioUrl = sourceAudioUrl,
+ sourceHeaders = sourceHeaders,
+ sourceResponseHeaders = sourceResponseHeaders,
+ providerName = providerName,
+ streamTitle = streamTitle,
+ streamSubtitle = streamSubtitle,
+ initialBingeGroup = initialBingeGroup,
+ pauseDescription = pauseDescription,
+ onBack = onBack,
+ onOpenInExternalPlayer = onOpenInExternalPlayer,
+ modifier = modifier,
+ logo = logo,
+ poster = poster,
+ background = background,
+ seasonNumber = seasonNumber,
+ episodeNumber = episodeNumber,
+ episodeTitle = episodeTitle,
+ episodeThumbnail = episodeThumbnail,
+ contentType = contentType,
+ videoId = videoId,
+ parentMetaId = parentMetaId,
+ parentMetaType = parentMetaType,
+ providerAddonId = providerAddonId,
+ torrentInfoHash = torrentInfoHash,
+ torrentFileIdx = torrentFileIdx,
+ torrentFilename = torrentFilename,
+ torrentMagnetUri = torrentMagnetUri,
+ torrentTrackers = torrentTrackers,
+ initialPositionMs = initialPositionMs,
+ initialProgressFraction = initialProgressFraction,
)
-
- fun emitTraktScrobbleStart() {
- if (hasRequestedScrobbleStartForCurrentItem) return
- hasRequestedScrobbleStartForCurrentItem = true
- val requestGeneration = scrobbleStartRequestGeneration + 1L
- scrobbleStartRequestGeneration = requestGeneration
-
- scope.launch {
- val item = currentTraktScrobbleItem()
- if (item == null) {
- hasRequestedScrobbleStartForCurrentItem = false
- return@launch
- }
- if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
- return@launch
- }
- currentTraktScrobbleItem = item
- TraktScrobbleRepository.scrobbleStart(
- item = item,
- progressPercent = currentPlaybackProgressPercent(),
- )
- }
- }
-
- fun emitTraktScrobbleStop(progressPercent: Float? = null) {
- val provided = progressPercent
- if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
-
- val percent = provided ?: currentPlaybackProgressPercent()
- val itemSnapshot = currentTraktScrobbleItem
- scope.launch(NonCancellable) {
- val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
- TraktScrobbleRepository.scrobbleStop(
- item = item,
- progressPercent = percent,
- )
- }
- currentTraktScrobbleItem = null
- hasRequestedScrobbleStartForCurrentItem = false
- scrobbleStartRequestGeneration += 1L
- }
-
- fun emitStopScrobbleForCurrentProgress() {
- val progressPercent = currentPlaybackProgressPercent()
- if (progressPercent >= 1f && progressPercent < 80f) {
- emitTraktScrobbleStop(progressPercent)
- return
- }
-
- if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) {
- hasSentCompletionScrobbleForCurrentItem = true
- emitTraktScrobbleStop(progressPercent)
- }
- }
-
- fun tryShowParentalGuide() {
- if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) {
- playbackStartedForParentalGuide = true
- controlsVisible = true
- showParentalGuide = true
- parentalGuideHasShown = true
- }
- }
-
- suspend fun resolveParentalGuideImdbId(): String? {
- val candidates = listOf(parentMetaId, activeVideoId)
- candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it }
- val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null
- return TmdbService.tmdbToImdb(
- tmdbId = tmdbId,
- mediaType = contentType ?: parentMetaType,
- )
- }
-
- fun flushWatchProgress() {
- emitStopScrobbleForCurrentProgress()
- WatchProgressRepository.flushPlaybackProgress(
- session = playbackSession,
- snapshot = playbackSnapshot,
- )
- }
-
- fun scheduleProgressSyncAfterSeek() {
- val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
- seekProgressSyncJob?.cancel()
- seekProgressSyncJob = scope.launch {
- delay(PlayerSeekProgressSyncDebounceMs)
- WatchProgressRepository.upsertPlaybackProgress(
- session = playbackSession,
- snapshot = playbackSnapshot,
- )
-
- val progressPercent = currentPlaybackProgressPercent()
- if (progressPercent >= 1f && progressPercent < 80f) {
- emitTraktScrobbleStop(progressPercent)
- val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
- if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
- pendingScrobbleStartAfterSeek = false
- emitTraktScrobbleStart()
- } else if (shouldRestartScrobbleNow) {
- pendingScrobbleStartAfterSeek = true
- }
- }
- }
- }
-
- val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) {
- {
- flushWatchProgress()
- onBack()
- }
- }
-
- var showAudioModal by remember { mutableStateOf(false) }
- var showSubtitleModal by remember { mutableStateOf(false) }
- var showVideoSettingsModal by remember { mutableStateOf(false) }
- var audioTracks by remember { mutableStateOf>(emptyList()) }
- var subtitleTracks by remember { mutableStateOf>(emptyList()) }
- var selectedAudioIndex by remember { mutableStateOf(-1) }
- var selectedSubtitleIndex by remember { mutableStateOf(-1) }
- var selectedAddonSubtitleId by remember { mutableStateOf(null) }
- var useCustomSubtitles by remember { mutableStateOf(false) }
- var preferredAudioSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) }
- var preferredSubtitleSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) }
- var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
- val subtitleStyle = playerSettingsUiState.subtitleStyle
- val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
- val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
- val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
- val activeAddonSubtitleType = contentType ?: parentMetaType
- val addonSubtitleFetchKey = remember(
- addonsUiState.addons,
- activeAddonSubtitleType,
- activeVideoId,
- ) {
- buildAddonSubtitleFetchKey(
- addons = addonsUiState.addons,
- type = activeAddonSubtitleType,
- videoId = activeVideoId,
- )
- }
- var autoFetchedAddonSubtitlesForKey by rememberSaveable(activePlaybackIdentity, activeVideoId) {
- mutableStateOf(null)
- }
- var trackPreferenceRestoreApplied by rememberSaveable(activePlaybackIdentity, parentMetaId) {
- mutableStateOf(false)
- }
- var subtitleDelayMs by rememberSaveable(playbackSession.videoId) {
- mutableStateOf(
- PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId)
- ?: 0
- )
- }
- var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) {
- mutableStateOf(SubtitleAutoSyncUiState())
- }
- val visibleAddonSubtitles = remember(
- addonSubtitles,
- playerSettingsUiState.preferredSubtitleLanguage,
- playerSettingsUiState.secondaryPreferredSubtitleLanguage,
- subtitleStyle.showOnlyPreferredLanguages,
- playerSettingsUiState.addonSubtitleStartupMode,
- selectedAddonSubtitleId,
- ) {
- filterAddonSubtitlesForSettings(
- subtitles = addonSubtitles,
- settings = playerSettingsUiState,
- selectedAddonSubtitleId = selectedAddonSubtitleId,
- )
- }
- val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) {
- addonSubtitles.firstOrNull { subtitle ->
- subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
- }
- }
-
- fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) {
- if (parentMetaId.isBlank()) return
- val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
- PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
- }
-
- fun persistAudioPreference(track: AudioTrack?) {
- updateTrackPreference { current ->
- current.copy(
- audioLanguage = track?.language,
- audioName = track?.label,
- audioTrackId = track?.id,
- )
- }
- }
-
- fun persistInternalSubtitlePreference(track: SubtitleTrack?) {
- updateTrackPreference { current ->
- current.copy(
- subtitleType = if (track == null) {
- PersistedSubtitleSelectionType.DISABLED
- } else {
- PersistedSubtitleSelectionType.INTERNAL
- },
- subtitleLanguage = track?.language,
- subtitleName = track?.label,
- subtitleTrackId = track?.id,
- addonSubtitleId = null,
- addonSubtitleUrl = null,
- addonSubtitleAddonName = null,
- )
- }
- }
-
- fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
- updateTrackPreference { current ->
- current.copy(
- subtitleType = PersistedSubtitleSelectionType.ADDON,
- subtitleLanguage = subtitle.language,
- subtitleName = subtitle.display,
- subtitleTrackId = null,
- addonSubtitleId = subtitle.id,
- addonSubtitleUrl = subtitle.url,
- addonSubtitleAddonName = subtitle.addonName,
- )
- }
- }
-
- fun restorePersistedTrackPreferenceIfNeeded() {
- if (trackPreferenceRestoreApplied) return
- val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
- if (preference == null) {
- trackPreferenceRestoreApplied = true
- return
- }
-
- if (
- audioTracks.isNotEmpty() &&
- (!preference.audioTrackId.isNullOrBlank() ||
- !preference.audioLanguage.isNullOrBlank() ||
- !preference.audioName.isNullOrBlank())
- ) {
- val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
- if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
- playerController?.selectAudioTrack(restoredAudioIndex)
- selectedAudioIndex = restoredAudioIndex
- }
- preferredAudioSelectionApplied = true
- }
-
- when (preference.subtitleType) {
- PersistedSubtitleSelectionType.DISABLED -> {
- playerController?.selectSubtitleTrack(-1)
- selectedSubtitleIndex = -1
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- preferredSubtitleSelectionApplied = true
- }
- PersistedSubtitleSelectionType.INTERNAL -> {
- if (subtitleTracks.isNotEmpty()) {
- val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
- if (restoredSubtitleIndex >= 0) {
- if (useCustomSubtitles) {
- playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
- } else {
- playerController?.selectSubtitleTrack(restoredSubtitleIndex)
- }
- selectedSubtitleIndex = restoredSubtitleIndex
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- preferredSubtitleSelectionApplied = true
- }
- }
- }
- PersistedSubtitleSelectionType.ADDON -> {
- val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
- if (url != null) {
- selectedAddonSubtitleId = preference.addonSubtitleId ?: url
- selectedSubtitleIndex = -1
- useCustomSubtitles = true
- playerController?.setSubtitleUri(url)
- preferredSubtitleSelectionApplied = true
- }
- }
- }
-
- trackPreferenceRestoreApplied = true
- }
-
- fun refreshTracks() {
- val ctrl = playerController ?: return
- audioTracks = ctrl.getAudioTracks()
- subtitleTracks = ctrl.getSubtitleTracks()
- val selectedAudio = audioTracks.firstOrNull { it.isSelected }
- if (selectedAudio != null) selectedAudioIndex = selectedAudio.index
- val selectedSub = subtitleTracks.firstOrNull { it.isSelected }
- if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index
-
- restorePersistedTrackPreferenceIfNeeded()
-
- if (!preferredAudioSelectionApplied) {
- val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
- preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
- secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
- deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
- )
- if (preferredAudioTargets.isEmpty()) {
- preferredAudioSelectionApplied = true
- } else if (audioTracks.isNotEmpty()) {
- val preferredAudioIndex = findPreferredTrackIndex(
- tracks = audioTracks,
- targets = preferredAudioTargets,
- language = { track -> track.language },
- )
- if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) {
- playerController?.selectAudioTrack(preferredAudioIndex)
- selectedAudioIndex = preferredAudioIndex
- }
- preferredAudioSelectionApplied = true
- }
- }
-
- if (!preferredSubtitleSelectionApplied) {
- val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
- preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
- SubtitleLanguageOption.FORCED
- } else {
- playerSettingsUiState.preferredSubtitleLanguage
- },
- secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
- deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
- )
-
- if (preferredSubtitleTargets.isEmpty()) {
- if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
- playerController?.selectSubtitleTrack(-1)
- }
- selectedSubtitleIndex = -1
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- preferredSubtitleSelectionApplied = true
- } else if (subtitleTracks.isNotEmpty()) {
- val preferredSubtitleIndex = findPreferredSubtitleTrackIndex(
- tracks = subtitleTracks,
- targets = preferredSubtitleTargets,
- )
- if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) {
- playerController?.selectSubtitleTrack(preferredSubtitleIndex)
- selectedSubtitleIndex = preferredSubtitleIndex
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- } else if (
- preferredSubtitleIndex < 0 &&
- (subtitleStyle.useForcedSubtitles ||
- normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED)
- ) {
- if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
- playerController?.selectSubtitleTrack(-1)
- }
- selectedSubtitleIndex = -1
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- }
- preferredSubtitleSelectionApplied = true
- }
- }
-
- }
-
- fun showGestureFeedback(feedback: GestureFeedbackState) {
- gestureMessageJob?.cancel()
- gestureFeedback = feedback
- gestureMessageJob = scope.launch {
- delay(900)
- gestureFeedback = null
- }
- }
-
- fun showGestureMessage(message: String) {
- showGestureFeedback(GestureFeedbackState(message = message))
- }
-
- fun clearLiveGestureFeedback() {
- liveGestureFeedback = null
- }
-
- fun revealLockedOverlay() {
- controlsVisible = false
- lockedOverlayVisible = true
- }
-
- fun lockPlayerControls() {
- playerControlsLocked = true
- controlsVisible = false
- lockedOverlayVisible = false
- pausedOverlayVisible = false
- isScrubbingTimeline = false
- scrubbingPositionMs = null
- gestureMessageJob?.cancel()
- gestureFeedback = null
- liveGestureFeedback = null
- renderedGestureFeedback = null
- showAudioModal = false
- showSubtitleModal = false
- showVideoSettingsModal = false
- showSourcesPanel = false
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- PlayerStreamsRepository.clearEpisodeStreams()
- }
-
- fun unlockPlayerControls() {
- playerControlsLocked = false
- lockedOverlayVisible = false
- controlsVisible = true
- }
-
- fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
- val seconds = amountMs / 1000L
- if (seconds <= 0L) return
- showGestureFeedback(
- GestureFeedbackState(
- messageRes = if (direction == PlayerSeekDirection.Forward) {
- Res.string.compose_player_seek_feedback_forward
- } else {
- Res.string.compose_player_seek_feedback_backward
- },
- messageArgs = listOf(seconds),
- icon = if (direction == PlayerSeekDirection.Forward) {
- GestureFeedbackIcon.SeekForward
- } else {
- GestureFeedbackIcon.SeekBackward
- },
- ),
- )
- }
-
- fun showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) {
- val deltaMs = previewPositionMs - baselinePositionMs
- val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward
- liveGestureFeedback = GestureFeedbackState(
- message = formatPlaybackTime(previewPositionMs),
- icon = if (direction == PlayerSeekDirection.Forward) {
- GestureFeedbackIcon.SeekForward
- } else {
- GestureFeedbackIcon.SeekBackward
- },
- secondaryMessageRes = if (deltaMs >= 0L) {
- Res.string.compose_player_seek_delta_forward
- } else {
- Res.string.compose_player_seek_delta_backward
- },
- secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()),
- secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
- Color(0xFF6EE7A8)
- } else {
- Color(0xFFFF9A76)
- },
- )
- }
-
- fun showBrightnessFeedback(level: Float) {
- val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
- showGestureFeedback(
- GestureFeedbackState(
- messageRes = Res.string.compose_player_brightness_level,
- messageArgs = listOf("$percentage%"),
- icon = GestureFeedbackIcon.Brightness,
- ),
- )
- }
-
- fun showVolumeFeedback(level: PlayerAudioLevel) {
- val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
- showGestureFeedback(
- GestureFeedbackState(
- messageRes = if (level.isMuted) {
- Res.string.compose_player_muted
- } else {
- Res.string.compose_player_volume_level
- },
- messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"),
- icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
- isDanger = level.isMuted,
- ),
- )
- }
-
- fun togglePlayback() {
- if (playbackSnapshot.isPlaying) {
- shouldPlay = false
- playerController?.pause()
- } else {
- if (playbackSnapshot.isEnded) {
- playerController?.seekTo(0L)
- }
- shouldPlay = true
- playerController?.play()
- }
- controlsVisible = true
- }
-
- fun seekBy(offsetMs: Long) {
- playerController?.seekBy(offsetMs)
- scheduleProgressSyncAfterSeek()
- controlsVisible = true
- when {
- offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
- offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
- }
- }
-
- fun handleDoubleTapSeek(direction: PlayerSeekDirection) {
- val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
- val nextState = if (accumulatedSeekState?.direction == direction) {
- accumulatedSeekState!!.copy(amountMs = accumulatedSeekState!!.amountMs + PlayerDoubleTapSeekStepMs)
- } else {
- PlayerAccumulatedSeekState(
- direction = direction,
- baselinePositionMs = currentPositionMs,
- amountMs = PlayerDoubleTapSeekStepMs,
- )
- }
- accumulatedSeekState = nextState
-
- val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L }
- val targetPositionMs = when (direction) {
- PlayerSeekDirection.Backward -> {
- (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L)
- }
-
- PlayerSeekDirection.Forward -> {
- val unclamped = nextState.baselinePositionMs + nextState.amountMs
- maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
- }
- }
- playerController?.seekTo(targetPositionMs)
- scheduleProgressSyncAfterSeek()
- showSeekFeedback(direction, nextState.amountMs)
-
- accumulatedSeekResetJob?.cancel()
- accumulatedSeekResetJob = scope.launch {
- delay(PlayerDoubleTapSeekResetDelayMs)
- accumulatedSeekState = null
- }
- }
-
- fun cycleResizeMode() {
- val nextMode = resizeMode.next()
- resizeMode = nextMode
- PlayerSettingsRepository.setResizeMode(nextMode)
- showGestureMessage(
- when (nextMode) {
- PlayerResizeMode.Fit -> resizeModeFitLabel
- PlayerResizeMode.Fill -> resizeModeFillLabel
- PlayerResizeMode.Zoom -> resizeModeZoomLabel
- },
- )
- controlsVisible = true
- }
-
- fun cyclePlaybackSpeed() {
- val speeds = listOf(1f, 1.25f, 1.5f, 2f)
- val current = playbackSnapshot.playbackSpeed
- val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first()
- playerController?.setPlaybackSpeed(next)
- showGestureMessage(formatPlaybackSpeedLabel(next))
- controlsVisible = true
- }
-
- fun activateHoldToSpeed() {
- if (!playerSettingsUiState.holdToSpeedEnabled) return
- val controller = playerController ?: return
- if (speedBoostRestoreSpeed != null) return
-
- val targetSpeed = playerSettingsUiState.holdToSpeedValue
- val currentSpeed = playbackSnapshot.playbackSpeed
- if (abs(currentSpeed - targetSpeed) < 0.01f) return
-
- isHoldToSpeedGestureActive = true
- speedBoostRestoreSpeed = currentSpeed
- controller.setPlaybackSpeed(targetSpeed)
- liveGestureFeedback = GestureFeedbackState(
- message = formatPlaybackSpeedLabel(targetSpeed),
- icon = GestureFeedbackIcon.Speed,
- )
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
-
- fun deactivateHoldToSpeed() {
- isHoldToSpeedGestureActive = false
- val restoreSpeed = speedBoostRestoreSpeed ?: return
- playerController?.setPlaybackSpeed(restoreSpeed)
- speedBoostRestoreSpeed = null
- liveGestureFeedback = null
- }
-
- val onSurfaceTap = rememberUpdatedState { offset: Offset ->
- if (playerControlsLocked) {
- revealLockedOverlay()
- return@rememberUpdatedState
- }
- val centerStart = layoutSize.width * PlayerLeftGestureBoundary
- val centerEnd = layoutSize.width * PlayerRightGestureBoundary
- if (controlsVisible && offset.x in centerStart..centerEnd) {
- controlsVisible = false
- } else {
- controlsVisible = !controlsVisible
- }
- }
- val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
- if (playerControlsLocked) {
- revealLockedOverlay()
- return@rememberUpdatedState
- }
- when {
- offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
- handleDoubleTapSeek(PlayerSeekDirection.Backward)
- }
-
- offset.x > layoutSize.width * PlayerRightGestureBoundary -> {
- handleDoubleTapSeek(PlayerSeekDirection.Forward)
- }
-
- else -> controlsVisible = !controlsVisible
- }
- }
- val activateHoldToSpeedState = rememberUpdatedState(::activateHoldToSpeed)
- val deactivateHoldToSpeedState = rememberUpdatedState(::deactivateHoldToSpeed)
- val showHorizontalSeekPreviewState = rememberUpdatedState(::showHorizontalSeekPreview)
- val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
- val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
- val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
- val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
- val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
- val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
- val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
- val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
- val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
- playerController?.seekTo(targetPositionMs)
- scheduleProgressSyncAfterSeek()
- }
-
- fun resolveDebridForPlayer(
- stream: StreamItem,
- season: Int?,
- episode: Int?,
- onResolved: (StreamItem) -> Unit,
- onStale: () -> Unit,
- ): Boolean {
- if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false
- scope.launch {
- val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
- stream = stream,
- season = season,
- episode = episode,
- )
- when (resolved) {
- is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
- else -> {
- resolved.toastMessage()?.let { NuvioToastController.show(it) }
- if (resolved == DirectDebridPlayableResult.Stale) {
- onStale()
- }
- }
- }
- }
- return true
- }
-
- fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
- "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
-
- fun isP2pStream(stream: StreamItem): Boolean =
- stream.needsLocalDebridResolve && stream.p2pInfoHash != null
-
- fun stopActiveP2pStream() {
- if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) {
- P2pStreamingEngine.stopStream()
- }
- activeTorrentInfoHash = null
- activeTorrentFileIdx = null
- activeTorrentFilename = null
- activeTorrentMagnetUri = null
- activeTorrentTrackers = emptyList()
- p2pResolvedSourceUrl = null
- }
-
- fun saveP2pStreamForReuse(
- stream: StreamItem,
- videoId: String?,
- season: Int?,
- episode: Int?,
- ) {
- if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return
- val infoHash = stream.p2pInfoHash ?: return
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = contentType ?: parentMetaType,
- videoId = videoId,
- parentMetaId = parentMetaId,
- season = season,
- episode = episode,
- )
- StreamLinkCacheRepository.save(
- contentKey = cacheKey,
- url = "",
- streamName = stream.streamLabel,
- addonName = stream.addonName,
- addonId = stream.addonId,
- requestHeaders = emptyMap(),
- responseHeaders = emptyMap(),
- filename = stream.p2pFilename,
- videoSize = stream.behaviorHints.videoSize,
- infoHash = infoHash,
- fileIdx = stream.p2pFileIdx,
- magnetUri = stream.torrentMagnetUri,
- sources = stream.p2pSourceHints,
- bingeGroup = stream.behaviorHints.bingeGroup,
- )
- }
-
- fun switchToP2pSourceStream(stream: StreamItem) {
- val infoHash = stream.p2pInfoHash ?: return
- if (!P2pSettingsRepository.isVisible) return
- if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
- pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false)
- return
- }
- val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
- flushWatchProgress()
- stopActiveP2pStream()
- saveP2pStreamForReuse(
- stream = stream,
- videoId = activeVideoId,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- )
- activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
- activeSourceAudioUrl = null
- activeSourceHeaders = emptyMap()
- activeSourceResponseHeaders = emptyMap()
- activeTorrentInfoHash = infoHash
- activeTorrentFileIdx = stream.p2pFileIdx
- activeTorrentFilename = stream.p2pFilename
- activeTorrentMagnetUri = stream.torrentMagnetUri
- activeTorrentTrackers = stream.p2pTrackers
- activeStreamTitle = stream.streamLabel
- activeStreamSubtitle = stream.streamSubtitle
- activeProviderName = stream.addonName
- activeProviderAddonId = stream.addonId
- currentStreamBingeGroup = stream.behaviorHints.bingeGroup
- activeInitialPositionMs = currentPositionMs
- activeInitialProgressFraction = null
- showSourcesPanel = false
- controlsVisible = true
- }
-
- fun switchToP2pEpisodeStream(stream: StreamItem, episode: MetaVideo, isAutoPlay: Boolean = false) {
- val infoHash = stream.p2pInfoHash ?: return
- if (!P2pSettingsRepository.isVisible) return
- if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
- pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay)
- return
- }
- showNextEpisodeCard = false
- showSourcesPanel = false
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- nextEpisodeAutoPlayJob?.cancel()
- nextEpisodeAutoPlaySearching = false
- nextEpisodeAutoPlaySourceName = null
- nextEpisodeAutoPlayCountdown = null
- PlayerStreamsRepository.clearEpisodeStreams()
- flushWatchProgress()
- stopActiveP2pStream()
- val epVideoId = episode.id
- val epResumeVideoId = buildPlaybackVideoId(
- parentMetaId = parentMetaId,
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- fallbackVideoId = epVideoId,
- )
- val epEntry = WatchProgressRepository.progressForVideo(
- epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
- )
- ?.takeIf { !it.isCompleted }
- val epResumeFraction = epEntry?.progressPercent
- ?.takeIf { it > 0f }
- ?.let { (it / 100f).coerceIn(0f, 1f) }
- val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
- saveP2pStreamForReuse(
- stream = stream,
- videoId = epVideoId,
- season = episode.season,
- episode = episode.episode,
- )
- activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
- activeSourceAudioUrl = null
- activeSourceHeaders = emptyMap()
- activeSourceResponseHeaders = emptyMap()
- activeTorrentInfoHash = infoHash
- activeTorrentFileIdx = stream.p2pFileIdx
- activeTorrentFilename = stream.p2pFilename
- activeTorrentMagnetUri = stream.torrentMagnetUri
- activeTorrentTrackers = stream.p2pTrackers
- activeStreamTitle = stream.streamLabel
- activeStreamSubtitle = stream.streamSubtitle
- activeProviderName = stream.addonName
- activeProviderAddonId = stream.addonId
- currentStreamBingeGroup = stream.behaviorHints.bingeGroup
- activeSeasonNumber = episode.season
- activeEpisodeNumber = episode.episode
- activeEpisodeTitle = episode.title
- activeEpisodeThumbnail = episode.thumbnail
- activeVideoId = episode.id
- activeInitialPositionMs = epResumePositionMs
- activeInitialProgressFraction = epResumeFraction
- controlsVisible = true
- }
-
- fun switchToSource(stream: StreamItem) {
- if (
- resolveDebridForPlayer(
- stream = stream,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- onResolved = ::switchToSource,
- onStale = {
- val type = contentType ?: parentMetaType
- val vid = activeVideoId
- if (vid != null) {
- PlayerStreamsRepository.loadSources(
- type = type,
- videoId = vid,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- forceRefresh = true,
- )
- }
- },
- )
- ) return
- if (isP2pStream(stream)) {
- switchToP2pSourceStream(stream)
- return
- }
- val url = stream.playableDirectUrl ?: return
- if (url == activeSourceUrl) return
- val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
- flushWatchProgress()
- stopActiveP2pStream()
- if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = contentType ?: parentMetaType,
- videoId = activeVideoId!!,
- parentMetaId = parentMetaId,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- )
- StreamLinkCacheRepository.save(
- contentKey = cacheKey,
- url = url,
- streamName = stream.streamLabel,
- addonName = stream.addonName,
- addonId = stream.addonId,
- requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- filename = stream.behaviorHints.filename,
- videoSize = stream.behaviorHints.videoSize,
- bingeGroup = stream.behaviorHints.bingeGroup,
- )
- }
- activeSourceUrl = url
- activeSourceAudioUrl = null
- activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
- activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
- activeStreamTitle = stream.streamLabel
- activeStreamSubtitle = stream.streamSubtitle
- activeProviderName = stream.addonName
- activeProviderAddonId = stream.addonId
- currentStreamBingeGroup = stream.behaviorHints.bingeGroup
- activeInitialPositionMs = currentPositionMs
- activeInitialProgressFraction = null
- showSourcesPanel = false
- controlsVisible = true
- }
-
- fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
- if (
- resolveDebridForPlayer(
- stream = stream,
- season = episode.season,
- episode = episode.episode,
- onResolved = { resolvedStream ->
- switchToEpisodeStream(resolvedStream, episode)
- },
- onStale = {
- val type = contentType ?: parentMetaType
- PlayerStreamsRepository.loadEpisodeStreams(
- type = type,
- videoId = episode.id,
- season = episode.season,
- episode = episode.episode,
- forceRefresh = true,
- )
- },
- )
- ) return
- if (isP2pStream(stream)) {
- switchToP2pEpisodeStream(stream, episode)
- return
- }
- val url = stream.playableDirectUrl ?: return
- showNextEpisodeCard = false
- showSourcesPanel = false
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- nextEpisodeAutoPlayJob?.cancel()
- nextEpisodeAutoPlaySearching = false
- nextEpisodeAutoPlaySourceName = null
- nextEpisodeAutoPlayCountdown = null
- PlayerStreamsRepository.clearEpisodeStreams()
- flushWatchProgress()
- stopActiveP2pStream()
- val epVideoId = episode.id
- val epResumeVideoId = buildPlaybackVideoId(
- parentMetaId = parentMetaId,
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- fallbackVideoId = epVideoId,
- )
- val epEntry = WatchProgressRepository.progressForVideo(
- epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
- )
- ?.takeIf { !it.isCompleted }
- val epResumeFraction = epEntry?.progressPercent
- ?.takeIf { it > 0f }
- ?.let { (it / 100f).coerceIn(0f, 1f) }
- val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
- if (playerSettingsUiState.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = contentType ?: parentMetaType,
- videoId = epVideoId,
- parentMetaId = parentMetaId,
- season = episode.season,
- episode = episode.episode,
- )
- StreamLinkCacheRepository.save(
- contentKey = cacheKey,
- url = url,
- streamName = stream.streamLabel,
- addonName = stream.addonName,
- addonId = stream.addonId,
- requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- filename = stream.behaviorHints.filename,
- videoSize = stream.behaviorHints.videoSize,
- bingeGroup = stream.behaviorHints.bingeGroup,
- )
- }
- activeSourceUrl = url
- activeSourceAudioUrl = null
- activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
- activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
- activeStreamTitle = stream.streamLabel
- activeStreamSubtitle = stream.streamSubtitle
- activeProviderName = stream.addonName
- activeProviderAddonId = stream.addonId
- currentStreamBingeGroup = stream.behaviorHints.bingeGroup
- activeSeasonNumber = episode.season
- activeEpisodeNumber = episode.episode
- activeEpisodeTitle = episode.title
- activeEpisodeThumbnail = episode.thumbnail
- activeVideoId = episode.id
- activeInitialPositionMs = epResumePositionMs
- activeInitialProgressFraction = epResumeFraction
- controlsVisible = true
- }
-
- fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
- val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
- showNextEpisodeCard = false
- showSourcesPanel = false
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- nextEpisodeAutoPlayJob?.cancel()
- nextEpisodeAutoPlaySearching = false
- nextEpisodeAutoPlaySourceName = null
- nextEpisodeAutoPlayCountdown = null
- PlayerStreamsRepository.clearEpisodeStreams()
- flushWatchProgress()
- stopActiveP2pStream()
-
- val fallbackVideoId = buildPlaybackVideoId(
- parentMetaId = parentMetaId,
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- fallbackVideoId = episode.id,
- )
- val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId
- val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId)
- ?.takeIf { !it.isCompleted }
- val epResumeFraction = epEntry?.progressPercent
- ?.takeIf { it > 0f }
- ?.let { (it / 100f).coerceIn(0f, 1f) }
- val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
-
- activeSourceUrl = localFileUri
- activeSourceAudioUrl = null
- activeSourceHeaders = emptyMap()
- activeSourceResponseHeaders = emptyMap()
- activeStreamTitle = downloadItem.streamTitle.ifBlank {
- episode.title.ifBlank { title }
- }
- activeStreamSubtitle = downloadItem.streamSubtitle
- activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel }
- activeProviderAddonId = downloadItem.providerAddonId
- currentStreamBingeGroup = null
- activeSeasonNumber = episode.season
- activeEpisodeNumber = episode.episode
- activeEpisodeTitle = episode.title
- activeEpisodeThumbnail = episode.thumbnail
- activeVideoId = resolvedVideoId
- activeInitialPositionMs = epResumePositionMs
- activeInitialProgressFraction = epResumeFraction
- controlsVisible = true
- }
-
- fun playNextEpisode() {
- val nextVideoId = nextEpisodeInfo?.videoId ?: return
- val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return
- if (nextEpisodeInfo?.hasAired != true) return
-
- val downloadedNextEpisode = DownloadsRepository.findPlayableDownload(
- parentMetaId = parentMetaId,
- seasonNumber = nextVideo.season,
- episodeNumber = nextVideo.episode,
- videoId = nextVideo.id,
- )
- if (downloadedNextEpisode != null) {
- switchToDownloadedEpisode(downloadedNextEpisode, nextVideo)
- return
- }
-
- nextEpisodeAutoPlayJob?.cancel()
- nextEpisodeAutoPlaySearching = true
- nextEpisodeAutoPlaySourceName = null
- nextEpisodeAutoPlayCountdown = null
-
- val type = contentType ?: parentMetaType
- val settings = playerSettingsUiState
- val shouldAutoSelectInManualMode =
- settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL &&
- (
- settings.streamAutoPlayNextEpisodeEnabled ||
- settings.streamAutoPlayPreferBingeGroup
- )
-
- // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle)
- val bingeGroupOnlyManualMode =
- shouldAutoSelectInManualMode &&
- !settings.streamAutoPlayNextEpisodeEnabled &&
- settings.streamAutoPlayPreferBingeGroup
-
- // Determine auto-play mode for next episode
- val effectiveMode = if (shouldAutoSelectInManualMode) {
- StreamAutoPlayMode.FIRST_STREAM
- } else {
- settings.streamAutoPlayMode
- }
- val effectiveSource = if (shouldAutoSelectInManualMode) {
- StreamAutoPlaySource.ALL_SOURCES
- } else {
- settings.streamAutoPlaySource
- }
- val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) {
- emptySet()
- } else {
- settings.streamAutoPlaySelectedAddons
- }
- val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) {
- emptySet()
- } else {
- settings.streamAutoPlaySelectedPlugins
- }
- val effectiveRegex = if (shouldAutoSelectInManualMode) {
- ""
- } else {
- settings.streamAutoPlayRegex
- }
-
- // Determine preferred binge group from current stream (not cache)
- val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
- currentStreamBingeGroup
- } else {
- null
- }
-
- nextEpisodeAutoPlayJob = scope.launch {
- PlayerStreamsRepository.loadEpisodeStreams(
- type = type,
- videoId = nextVideo.id,
- season = nextVideo.season,
- episode = nextVideo.episode,
- )
-
- val installedAddonNames = AddonRepository.uiState.value.addons
- .enabledAddons()
- .map { it.displayTitle }
- .toSet()
- val debridSettings = DebridSettingsRepository.snapshot()
-
- val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
- var autoSelectTriggered = false
- var timeoutElapsed = false
- var selectedStream: StreamItem? = null
- val autoSelectSettled = CompletableDeferred()
-
- fun settleAutoSelect() {
- if (!autoSelectSettled.isCompleted) {
- autoSelectSettled.complete(Unit)
- }
- }
-
- fun selectStream(stream: StreamItem) {
- autoSelectTriggered = true
- selectedStream = stream
- settleAutoSelect()
- }
-
- fun finishWithoutSelection() {
- autoSelectTriggered = true
- settleAutoSelect()
- }
-
- // Full select: tries binge group first, then falls back to mode-based selection
- fun trySelectStream(streams: List): StreamItem? {
- return StreamAutoPlaySelector.selectAutoPlayStream(
- streams = streams,
- mode = effectiveMode,
- regexPattern = effectiveRegex,
- source = effectiveSource,
- installedAddonNames = installedAddonNames,
- selectedAddons = effectiveSelectedAddons,
- selectedPlugins = effectiveSelectedPlugins,
- preferredBingeGroup = preferredBingeGroup,
- preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
- bingeGroupOnly = bingeGroupOnlyManualMode,
- debridEnabled = debridSettings.canResolvePlayableLinks,
- activeResolverProviderId = debridSettings.activeResolverProviderId,
- )
- }
-
- // Binge group only early match: returns null if no binge group match
- fun tryBingeGroupOnly(streams: List): StreamItem? {
- if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null
- return StreamAutoPlaySelector.selectAutoPlayStream(
- streams = streams,
- mode = effectiveMode,
- regexPattern = effectiveRegex,
- source = effectiveSource,
- installedAddonNames = installedAddonNames,
- selectedAddons = effectiveSelectedAddons,
- selectedPlugins = effectiveSelectedPlugins,
- preferredBingeGroup = preferredBingeGroup,
- preferBingeGroupInSelection = true,
- bingeGroupOnly = true,
- debridEnabled = debridSettings.canResolvePlayableLinks,
- activeResolverProviderId = debridSettings.activeResolverProviderId,
- )
- }
-
- val innerJob = launch {
- // Collect streams as they arrive
- PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
- if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
-
- val allStreams = state.groups.flatMap { it.streams }
-
- if (autoSelectTriggered) {
- // Already resolved
- } else if (timeoutElapsed) {
- // Timeout elapsed: full select (binge group + fallback to mode)
- if (allStreams.isNotEmpty()) {
- val candidate = trySelectStream(allStreams)
- if (candidate != null) {
- selectStream(candidate)
- }
- }
- } else {
- // Before timeout: eagerly check binge group only
- if (allStreams.isNotEmpty()) {
- val earlyMatch = tryBingeGroupOnly(allStreams)
- if (earlyMatch != null) {
- selectStream(earlyMatch)
- }
- }
- }
-
- // If all addons finished loading and no match yet, do a final full select
- if (!autoSelectTriggered && !state.isAnyLoading) {
- if (allStreams.isNotEmpty()) {
- val candidate = trySelectStream(allStreams)
- if (candidate != null) {
- selectStream(candidate)
- }
- }
- if (!autoSelectTriggered) {
- finishWithoutSelection()
- }
- return@collectLatest
- }
-
- if (autoSelectTriggered) return@collectLatest
- }
- }
-
- // Timeout logic
- val timeoutMs = timeoutSeconds * 1_000L
- val isBoundedTimeout = timeoutSeconds in 1..30
-
- if (isBoundedTimeout) {
- // Bounded timeout (1-30s): wait, then trigger full select
- delay(timeoutMs)
- timeoutElapsed = true
- if (!autoSelectTriggered) {
- val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
- if (allStreams.isNotEmpty()) {
- val candidate = trySelectStream(allStreams)
- if (candidate != null) {
- selectStream(candidate)
- }
- }
- }
- if (selectedStream != null) {
- innerJob.cancel()
- } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
- // Streams arrived but no match after full select — don't wait further
- innerJob.cancel()
- finishWithoutSelection()
- } else {
- // No addon responded yet — wait with hard ceiling
- val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() }
- innerJob.cancel()
- if (completed == null) {
- if (!autoSelectTriggered) {
- val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
- if (allStreams.isNotEmpty()) {
- selectedStream = trySelectStream(allStreams)
- }
- finishWithoutSelection()
- }
- }
- }
- } else {
- // Instant (0) or unlimited: timeoutElapsed immediately so each
- // addon response triggers a full select attempt in the collect.
- timeoutElapsed = true
- if (!autoSelectTriggered) {
- val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
- if (allStreams.isNotEmpty()) {
- trySelectStream(allStreams)?.let(::selectStream)
- }
- }
- val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS
- val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() }
- innerJob.cancel()
- if (completed == null) {
- if (!autoSelectTriggered) {
- val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
- if (allStreams.isNotEmpty()) {
- selectedStream = trySelectStream(allStreams)
- }
- finishWithoutSelection()
- }
- }
- }
-
- // Handle result
- nextEpisodeAutoPlaySearching = false
- if (selectedStream != null) {
- nextEpisodeAutoPlaySourceName = selectedStream!!.addonName
- // Countdown before playing
- for (i in 3 downTo 1) {
- nextEpisodeAutoPlayCountdown = i
- delay(1000)
- }
- switchToEpisodeStream(selectedStream!!, nextVideo)
- showNextEpisodeCard = false
- nextEpisodeAutoPlayCountdown = null
- nextEpisodeAutoPlaySourceName = null
- } else {
- // No stream found — open the episode streams panel for manual selection
- episodeStreamsPanelState = EpisodeStreamsPanelState(
- showStreams = true,
- selectedEpisode = nextVideo,
- )
- showEpisodesPanel = true
- showNextEpisodeCard = false
- }
- }
- }
-
- fun openSourcesPanel() {
- val type = contentType ?: parentMetaType
- val vid = activeVideoId ?: return
- PlayerStreamsRepository.loadSources(
- type = type,
- videoId = vid,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- )
- showSourcesPanel = true
- showEpisodesPanel = false
- controlsVisible = false
- }
-
- fun openEpisodesPanel() {
- // Ensure meta is loaded for episodes
- if (allEpisodes.isEmpty()) {
- scope.launch {
- playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
- }
- }
- showEpisodesPanel = true
- showSourcesPanel = false
- controlsVisible = false
- }
-
- fun fetchAddonSubtitlesForActiveItem() {
- val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
- val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
- SubtitleRepository.fetchAddonSubtitles(type, videoId)
- }
-
- fun setSubtitleDelay(delayMs: Int) {
- val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
- subtitleDelayMs = clamped
- PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped)
- playerController?.setSubtitleDelayMs(clamped)
- }
-
- fun loadSubtitleAutoSyncCues(force: Boolean = false) {
- val subtitle = selectedAddonSubtitle ?: return
- if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return
- subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null)
- scope.launch {
- val result = runCatching {
- val body = httpGetTextWithHeaders(
- url = subtitle.url,
- headers = sanitizePlaybackHeaders(activeSourceHeaders),
- )
- PlayerSubtitleCueParser.parse(body, subtitle.url)
- }
- result.fold(
- onSuccess = { cues ->
- subtitleAutoSyncState = subtitleAutoSyncState.copy(
- cues = cues,
- isLoading = false,
- errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
- )
- },
- onFailure = { error ->
- subtitleAutoSyncState = subtitleAutoSyncState.copy(
- isLoading = false,
- errorMessage = error.message ?: "Unable to load subtitle lines",
- )
- },
- )
- }
- }
-
- fun captureSubtitleAutoSyncTime() {
- subtitleAutoSyncState = subtitleAutoSyncState.copy(
- capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L),
- errorMessage = null,
- )
- loadSubtitleAutoSyncCues()
- }
-
- fun applySubtitleAutoSyncCue(cue: SubtitleSyncCue) {
- val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return
- val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS)
- .toInt()
- .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
- setSubtitleDelay(newDelayMs)
- }
-
- LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
- errorMessage = null
- playerController = null
- playerControllerSourceUrl = null
- playbackSnapshot = PlayerPlaybackSnapshot()
- isScrubbingTimeline = false
- scrubbingPositionMs = null
- liveGestureFeedback = null
- renderedGestureFeedback = null
- lockedOverlayVisible = false
- initialLoadCompleted = false
- lastProgressPersistEpochMs = 0L
- previousIsPlaying = false
- pendingScrobbleStartAfterSeek = false
- seekProgressSyncJob?.cancel()
- seekProgressSyncJob = null
- accumulatedSeekResetJob?.cancel()
- accumulatedSeekResetJob = null
- accumulatedSeekState = null
- speedBoostRestoreSpeed = null
- preferredAudioSelectionApplied = false
- preferredSubtitleSelectionApplied = false
- showSourcesPanel = false
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- PlayerStreamsRepository.clearEpisodeStreams()
- SubtitleRepository.clear()
- WatchProgressRepository.ensureLoaded()
- }
-
- LaunchedEffect(
- activeTorrentInfoHash,
- activeTorrentFileIdx,
- activeTorrentFilename,
- activeTorrentMagnetUri,
- activeTorrentTrackers,
- p2pSettingsUiState.p2pEnabled,
- ) {
- val infoHash = activeTorrentInfoHash
- if (infoHash == null) {
- p2pResolvedSourceUrl = null
- P2pStreamingEngine.stopStream()
- return@LaunchedEffect
- }
- if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) {
- return@LaunchedEffect
- }
-
- p2pResolvedSourceUrl = null
- val requestedFileIdx = activeTorrentFileIdx
- val requestedFilename = activeTorrentFilename
- val requestedMagnetUri = activeTorrentMagnetUri
- val requestedTrackers = activeTorrentTrackers
- errorMessage = null
- playerController = null
- playerControllerSourceUrl = null
- playbackSnapshot = PlayerPlaybackSnapshot()
- initialLoadCompleted = false
-
- try {
- val localUrl = P2pStreamingEngine.startStream(
- P2pStreamRequest(
- infoHash = infoHash,
- fileIdx = requestedFileIdx,
- filename = requestedFilename,
- magnetUri = requestedMagnetUri,
- trackers = requestedTrackers,
- ),
- )
- if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) {
- activeSourceAudioUrl = null
- activeSourceHeaders = emptyMap()
- activeSourceResponseHeaders = emptyMap()
- p2pResolvedSourceUrl = localUrl
- }
- } catch (error: CancellationException) {
- throw error
- } catch (error: Exception) {
- errorMessage = getString(
- Res.string.player_error_failed_start_torrent,
- error.message ?: genericUnknownLabel,
- )
- controlsVisible = !playerControlsLocked
- initialLoadCompleted = true
- }
- }
-
- LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) {
- val state = p2pStreamingState
- if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) {
- errorMessage = getString(Res.string.player_error_torrent, state.message)
- controlsVisible = !playerControlsLocked
- }
- }
-
- LaunchedEffect(playbackSession.videoId) {
- subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0
- subtitleAutoSyncState = SubtitleAutoSyncUiState()
- }
-
- LaunchedEffect(playerController, subtitleDelayMs) {
- playerController?.setSubtitleDelayMs(subtitleDelayMs)
- }
-
- LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) {
- subtitleAutoSyncState = SubtitleAutoSyncUiState()
- }
-
- LaunchedEffect(playerController, subtitleStyle) {
- playerController?.applySubtitleStyle(subtitleStyle)
- }
-
- LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
- val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
- if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
- return@LaunchedEffect
- }
- if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
- autoFetchedAddonSubtitlesForKey = fetchKey
- fetchAddonSubtitlesForActiveItem()
- }
-
- LaunchedEffect(playbackSnapshot.isLoading, playerController) {
- if (!playbackSnapshot.isLoading && playerController != null) {
- refreshTracks()
- }
- }
-
- LaunchedEffect(
- playerController,
- playbackSnapshot.isLoading,
- preferredAudioSelectionApplied,
- preferredSubtitleSelectionApplied,
- ) {
- if (playerController == null || playbackSnapshot.isLoading) {
- return@LaunchedEffect
- }
- if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
- return@LaunchedEffect
- }
-
- repeat(10) {
- refreshTracks()
- if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
- return@LaunchedEffect
- }
- delay(300)
- }
- }
-
- LaunchedEffect(
- playerController,
- playerControllerSourceUrl,
- playbackSnapshot.isLoading,
- playbackSnapshot.durationMs,
- activeInitialPositionMs,
- activeInitialProgressFraction,
- initialSeekApplied,
- ) {
- val controller = playerController ?: return@LaunchedEffect
- if (playerControllerSourceUrl != activeSourceUrl) {
- return@LaunchedEffect
- }
- if (initialSeekApplied || playbackSnapshot.isLoading) {
- return@LaunchedEffect
- }
-
- val progressFraction = activeInitialProgressFraction
- ?.takeIf { it > 0f }
- ?.coerceIn(0f, 1f)
- val targetPositionMs = when {
- activeInitialPositionMs > 0L -> activeInitialPositionMs
- progressFraction != null && playbackSnapshot.durationMs > 0L -> {
- (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong()
- }
- progressFraction != null -> return@LaunchedEffect
- else -> 0L
- }
- if (targetPositionMs <= 0L) {
- initialSeekApplied = true
- return@LaunchedEffect
- }
-
- controller.seekTo(targetPositionMs)
- initialSeekApplied = true
- }
-
- LaunchedEffect(
- controlsVisible,
- isScrubbingTimeline,
- playbackSnapshot.isPlaying,
- playbackSnapshot.isLoading,
- showParentalGuide,
- errorMessage,
- ) {
- if (
- !controlsVisible ||
- isScrubbingTimeline ||
- !playbackSnapshot.isPlaying ||
- playbackSnapshot.isLoading ||
- showParentalGuide ||
- errorMessage != null
- ) {
- return@LaunchedEffect
- }
- delay(3500)
- controlsVisible = false
- }
-
- LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
- if (!playerControlsLocked || !lockedOverlayVisible) {
- return@LaunchedEffect
- }
- delay(PlayerLockedOverlayDurationMs)
- lockedOverlayVisible = false
- }
-
- LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
- pausedOverlayVisible = false
- if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
- return@LaunchedEffect
- }
- delay(5000)
- pausedOverlayVisible = true
- }
-
- LaunchedEffect(
- playbackSnapshot.positionMs,
- playbackSnapshot.isPlaying,
- playbackSnapshot.isLoading,
- playbackSnapshot.isEnded,
- playbackSnapshot.durationMs,
- ) {
- if (playbackSnapshot.isEnded) {
- flushWatchProgress()
- previousIsPlaying = false
- pendingScrobbleStartAfterSeek = false
- return@LaunchedEffect
- }
-
- if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
- pendingScrobbleStartAfterSeek = false
- flushWatchProgress()
- }
-
- if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
- pendingScrobbleStartAfterSeek = false
- emitTraktScrobbleStart()
- } else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
- emitTraktScrobbleStart()
- }
-
- if (!playbackSnapshot.isLoading) {
- previousIsPlaying = playbackSnapshot.isPlaying
- }
-
- if (!playbackSnapshot.isPlaying) {
- return@LaunchedEffect
- }
-
- val now = WatchProgressClock.nowEpochMs()
- if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) {
- return@LaunchedEffect
- }
- lastProgressPersistEpochMs = now
- WatchProgressRepository.upsertPlaybackProgress(
- session = playbackSession,
- snapshot = playbackSnapshot,
- syncRemote = false,
- )
- }
-
- // Fetch parental guide when the playable item changes.
- LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) {
- parentalWarnings = emptyList()
- showParentalGuide = false
- parentalGuideHasShown = false
- playbackStartedForParentalGuide = false
-
- val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect
- val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect
- parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels)
-
- if (playbackSnapshot.isPlaying) {
- tryShowParentalGuide()
- }
- }
-
- LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) {
- if (playbackSnapshot.isPlaying) {
- tryShowParentalGuide()
- }
- }
-
- // Fetch skip intervals when episode changes
- LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
- skipIntervals = emptyList()
- activeSkipInterval = null
- skipIntervalDismissed = false
- showNextEpisodeCard = false
- nextEpisodeAutoPlayJob?.cancel()
- nextEpisodeAutoPlaySearching = false
-
- val season = activeSeasonNumber
- val episode = activeEpisodeNumber
- val vid = activeVideoId
-
- if (season == null || episode == null || vid == null) return@LaunchedEffect
-
- launch {
- val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") }
- val intervals = SkipIntroRepository.getSkipIntervals(
- imdbId = imdbId,
- season = season,
- episode = episode,
- )
- skipIntervals = intervals
- }
- }
-
- // Update active skip interval based on playback position
- LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) {
- if (skipIntervals.isEmpty()) {
- activeSkipInterval = null
- return@LaunchedEffect
- }
- val positionSec = playbackSnapshot.positionMs / 1000.0
- val current = skipIntervals.firstOrNull { interval ->
- positionSec >= interval.startTime && positionSec < interval.endTime
- }
- if (current != activeSkipInterval) {
- activeSkipInterval = current
- if (current != null) skipIntervalDismissed = false
- }
- }
-
- // Resolve next episode info when episodes list or current episode changes
- LaunchedEffect(allEpisodes, activeSeasonNumber, activeEpisodeNumber) {
- if (!isSeries || allEpisodes.isEmpty()) {
- nextEpisodeInfo = null
- return@LaunchedEffect
- }
- val curSeason = activeSeasonNumber ?: return@LaunchedEffect
- val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect
- val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode(
- videos = allEpisodes,
- currentSeason = curSeason,
- currentEpisode = curEpisode,
- )
- nextEpisodeInfo = if (nextVideo != null && nextVideo.season != null && nextVideo.episode != null) {
- NextEpisodeInfo(
- videoId = nextVideo.id,
- season = nextVideo.season!!,
- episode = nextVideo.episode!!,
- title = nextVideo.title,
- thumbnail = nextVideo.thumbnail,
- overview = nextVideo.overview,
- released = nextVideo.released,
- hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
- unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
- "$airsPrefix ${nextVideo.released ?: tbaLabel}"
- } else null,
- )
- } else null
- }
-
- // Show next episode card at threshold
- LaunchedEffect(
- playbackSnapshot.positionMs,
- playbackSnapshot.durationMs,
- nextEpisodeInfo,
- skipIntervals,
- playerSettingsUiState.nextEpisodeThresholdMode,
- playerSettingsUiState.nextEpisodeThresholdPercent,
- playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
- ) {
- if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) {
- showNextEpisodeCard = false
- return@LaunchedEffect
- }
- val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard(
- positionMs = playbackSnapshot.positionMs,
- durationMs = playbackSnapshot.durationMs,
- skipIntervals = skipIntervals,
- thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode,
- thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent,
- thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
- )
- if (shouldShow && !showNextEpisodeCard) {
- showNextEpisodeCard = true
- // Auto-play if enabled
- if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
- playNextEpisode()
- }
- } else if (!shouldShow) {
- showNextEpisodeCard = false
- }
- }
-
- // Auto-play on video ended if next episode card isn't already showing
- LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) {
- if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) {
- showNextEpisodeCard = true
- if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
- playNextEpisode()
- }
- }
- }
-
- DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
- onDispose {
- flushWatchProgress()
- }
- }
-
- DisposableEffect(Unit) {
- onDispose {
- P2pStreamingEngine.shutdown()
- PlayerStreamsRepository.clearAll()
- }
- }
-
- Box(
- modifier = Modifier
- .fillMaxSize()
- .onSizeChanged { layoutSize = it }
- .pointerInput(layoutSize) {
- detectTapGestures(
- onPress = {
- tryAwaitRelease()
- deactivateHoldToSpeedState.value()
- },
- onTap = { offset -> onSurfaceTap.value(offset) },
- onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
- onLongPress = {
- if (playerControlsLockedState.value) {
- revealLockedOverlayState.value()
- } else {
- activateHoldToSpeedState.value()
- }
- },
- )
- }
- .pointerInput(gestureController, layoutSize) {
- awaitEachGesture {
- val down = awaitFirstDown()
- if (playerControlsLockedState.value) {
- while (true) {
- val event = awaitPointerEvent()
- val change = event.changes.firstOrNull { it.id == down.id } ?: break
- if (!change.pressed) break
- change.consume()
- }
- return@awaitEachGesture
- }
- val controller = gestureController
- val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
- val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
- val region = when {
- down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness
- down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume
- else -> null
- }
-
- val initialBrightness = if (region == PlayerSideGesture.Brightness) {
- controller?.currentBrightness()
- } else {
- null
- }
- val initialVolume = if (region == PlayerSideGesture.Volume) {
- controller?.currentVolume()
- } else {
- null
- }
-
- var totalDx = 0f
- var totalDy = 0f
- var gestureMode: PlayerGestureMode? = null
- val horizontalSeekBaselineMs = currentPositionMsState.value
- var horizontalSeekPreviewMs = horizontalSeekBaselineMs
-
- while (true) {
- val event = awaitPointerEvent()
- val change = event.changes.firstOrNull { it.id == down.id } ?: break
- if (!change.pressed) break
-
- val delta = change.position - change.previousPosition
- totalDx += delta.x
- totalDy += delta.y
-
- if (gestureMode == null) {
- val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
- val horizontalDominant =
- !holdToSpeedActive &&
- abs(totalDx) > viewConfiguration.touchSlop &&
- abs(totalDx) > abs(totalDy)
- val verticalDominant =
- !holdToSpeedActive &&
- abs(totalDy) > viewConfiguration.touchSlop &&
- abs(totalDy) > abs(totalDx)
-
- gestureMode = when {
- horizontalDominant -> {
- deactivateHoldToSpeedState.value()
- PlayerGestureMode.HorizontalSeek
- }
-
- verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> {
- PlayerGestureMode.Brightness
- }
-
- verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> {
- PlayerGestureMode.Volume
- }
-
- else -> null
- }
-
- if (gestureMode == null) {
- continue
- }
- }
-
- when (gestureMode) {
- PlayerGestureMode.HorizontalSeek -> {
- val sensitivitySeconds = when {
- currentDurationMsState.value >= 3_600_000L -> 120f
- currentDurationMsState.value >= 1_800_000L -> 90f
- else -> 60f
- }
- val previewOffsetMs =
- ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong()
- val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs
- horizontalSeekPreviewMs = currentDurationMsState.value
- .takeIf { it > 0L }
- ?.let { durationMs ->
- unclampedPreviewMs.coerceIn(0L, durationMs)
- }
- ?: unclampedPreviewMs.coerceAtLeast(0L)
- showHorizontalSeekPreviewState.value(
- horizontalSeekPreviewMs,
- horizontalSeekBaselineMs,
- )
- }
-
- PlayerGestureMode.Brightness -> {
- val gestureDeltaFraction =
- (-totalDy / height) * PlayerVerticalGestureSensitivity
- controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction)
- ?.let(showBrightnessFeedbackState.value)
- }
-
- PlayerGestureMode.Volume -> {
- val gestureDeltaFraction =
- (-totalDy / height) * PlayerVerticalGestureSensitivity
- controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction)
- ?.let(showVolumeFeedbackState.value)
- }
-
- null -> Unit
- }
- change.consume()
- }
-
- if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) {
- commitHorizontalSeekState.value(horizontalSeekPreviewMs)
- clearLiveGestureFeedbackState.value()
- }
- }
- },
- ) {
- val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
- if (playerSurfaceSourceUrl != null) {
- PlatformPlayerSurface(
- sourceUrl = playerSurfaceSourceUrl,
- sourceAudioUrl = activeSourceAudioUrl,
- sourceHeaders = activeSourceHeaders,
- sourceResponseHeaders = activeSourceResponseHeaders,
- modifier = Modifier.fillMaxSize(),
- playWhenReady = shouldPlay,
- resizeMode = resizeMode,
- onControllerReady = { controller ->
- playerController = controller
- playerControllerSourceUrl = activeSourceUrl
- },
- onSnapshot = { snapshot ->
- playbackSnapshot = snapshot
- if (!snapshot.isLoading) {
- initialLoadCompleted = true
- }
- if (snapshot.isEnded) {
- shouldPlay = false
- controlsVisible = !playerControlsLocked
- }
- },
- onError = { message ->
- errorMessage = message
- if (message != null) {
- controlsVisible = !playerControlsLocked
- val currentVideoId = activeVideoId
- if (currentVideoId != null) {
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = contentType ?: parentMetaType,
- videoId = currentVideoId,
- parentMetaId = parentMetaId,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- )
- StreamLinkCacheRepository.remove(cacheKey)
- }
- }
- },
- )
- }
-
- AnimatedVisibility(
- visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked,
- enter = fadeIn(animationSpec = tween(durationMillis = 220)),
- exit = fadeOut(animationSpec = tween(durationMillis = 180)),
- ) {
- PauseMetadataOverlay(
- title = title,
- logo = logo,
- isEpisode = isEpisode,
- seasonNumber = activeSeasonNumber,
- episodeNumber = activeEpisodeNumber,
- episodeTitle = activeEpisodeTitle,
- pauseDescription = pauseDescription ?: activeStreamSubtitle,
- providerName = activeProviderName,
- metrics = metrics,
- horizontalSafePadding = horizontalSafePadding,
- modifier = Modifier.fillMaxSize(),
- )
- }
-
- AnimatedVisibility(
- visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
- enter = fadeIn(),
- exit = fadeOut(),
- ) {
- PlayerControlsShell(
- title = title,
- streamTitle = activeStreamTitle,
- providerName = activeProviderName,
- seasonNumber = activeSeasonNumber,
- episodeNumber = activeEpisodeNumber,
- episodeTitle = activeEpisodeTitle,
- playbackSnapshot = playbackSnapshot,
- displayedPositionMs = displayedPositionMs,
- metrics = metrics,
- resizeMode = resizeMode,
- isLocked = playerControlsLocked,
- showPlaybackControls = controlsVisible,
- onLockToggle = {
- if (playerControlsLocked) {
- unlockPlayerControls()
- } else {
- lockPlayerControls()
- }
- },
- onBack = onBackWithProgress,
- onTogglePlayback = ::togglePlayback,
- onSeekBack = { seekBy(-10_000L) },
- onSeekForward = { seekBy(10_000L) },
- onResizeModeClick = ::cycleResizeMode,
- onSpeedClick = ::cyclePlaybackSpeed,
- onSubtitleClick = {
- refreshTracks()
- showSubtitleModal = true
- },
- onAudioClick = {
- refreshTracks()
- showAudioModal = true
- },
- onVideoSettingsClick = if (isIos) {
- {
- showVideoSettingsModal = true
- controlsVisible = true
- }
- } else {
- null
- },
- onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
- onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
- onOpenInExternalPlayer = if (onOpenInExternalPlayer != null) {
- {
- val currentPositionMs = playbackSnapshot.positionMs
- val loadedSubtitles = addonSubtitles
- .takeIf { it.isNotEmpty() }
- ?.map { sub ->
- SubtitleInput(
- url = sub.url,
- name = buildString {
- if (!sub.addonName.isNullOrBlank()) {
- append("[${sub.addonName}] ")
- }
- append(sub.display)
- },
- lang = sub.language,
- )
- }
- val request = ExternalPlayerPlaybackRequest(
- sourceUrl = activeSourceUrl,
- title = title,
- streamTitle = activeStreamTitle,
- sourceHeaders = activeSourceHeaders,
- resumePositionMs = currentPositionMs,
- subtitles = loadedSubtitles,
- )
- onOpenInExternalPlayer(request)
- }
- } else null,
- onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
- parentalWarnings = parentalWarnings,
- showParentalGuide = showParentalGuide,
- onParentalGuideAnimationComplete = { showParentalGuide = false },
- onScrubChange = { positionMs ->
- isScrubbingTimeline = true
- scrubbingPositionMs = positionMs
- },
- onScrubFinished = { positionMs ->
- isScrubbingTimeline = false
- scrubbingPositionMs = null
- playerController?.seekTo(positionMs)
- scheduleProgressSyncAfterSeek()
- },
- horizontalSafePadding = horizontalSafePadding,
- modifier = Modifier.fillMaxSize(),
- )
- }
-
- AnimatedVisibility(
- visible = playerControlsLocked && lockedOverlayVisible,
- enter = fadeIn(),
- exit = fadeOut(),
- ) {
- LockedPlayerOverlay(
- playbackSnapshot = playbackSnapshot,
- displayedPositionMs = displayedPositionMs,
- metrics = metrics,
- horizontalSafePadding = horizontalSafePadding,
- onUnlock = ::unlockPlayerControls,
- modifier = Modifier.fillMaxSize(),
- )
- }
-
- AnimatedVisibility(
- visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
- enter = fadeIn(),
- exit = fadeOut(),
- ) {
- OpeningOverlay(
- artwork = backdropArtwork,
- logo = logo,
- title = title,
- onBack = onBackWithProgress,
- horizontalSafePadding = horizontalSafePadding,
- modifier = Modifier.fillMaxSize(),
- message = p2pInitialLoadingMessage,
- progress = p2pInitialLoadingProgress,
- )
- }
-
- P2pLoadingStatus(
- visible = showP2pRebufferStats && errorMessage == null,
- message = p2pRebufferMessage,
- progress = p2pRebufferProgress,
- modifier = Modifier
- .align(Alignment.Center)
- .padding(top = 58.dp),
- )
-
- AnimatedVisibility(
- visible = currentGestureFeedback != null,
- enter = fadeIn(),
- exit = fadeOut(),
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- ) {
- renderedGestureFeedback?.let { feedback ->
- GestureFeedbackPill(
- feedback = feedback,
- modifier = Modifier
- .align(Alignment.TopCenter)
- .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
- .padding(horizontal = horizontalSafePadding)
- .padding(top = 40.dp),
- )
- }
- }
- }
-
- // Skip intro/recap/outro button
- if (!playerControlsLocked) {
- SkipIntroButton(
- interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval,
- dismissed = skipIntervalDismissed,
- controlsVisible = controlsVisible,
- onSkip = {
- val interval = activeSkipInterval ?: return@SkipIntroButton
- playerController?.seekTo((interval.endTime * 1000).toLong())
- scheduleProgressSyncAfterSeek()
- skipIntervalDismissed = true
- },
- onDismiss = { skipIntervalDismissed = true },
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
- )
- }
-
- // Next episode card
- if (isSeries && !playerControlsLocked) {
- NextEpisodeCard(
- nextEpisode = nextEpisodeInfo,
- visible = showNextEpisodeCard,
- isAutoPlaySearching = nextEpisodeAutoPlaySearching,
- autoPlaySourceName = nextEpisodeAutoPlaySourceName,
- autoPlayCountdownSec = nextEpisodeAutoPlayCountdown,
- onPlayNext = {
- nextEpisodeAutoPlayJob?.cancel()
- playNextEpisode()
- },
- onDismiss = {
- nextEpisodeAutoPlayJob?.cancel()
- showNextEpisodeCard = false
- nextEpisodeAutoPlaySearching = false
- nextEpisodeAutoPlaySourceName = null
- nextEpisodeAutoPlayCountdown = null
- },
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(end = sliderEdgePadding, bottom = overlayBottomPadding),
- )
- }
-
- if (errorMessage != null) {
- ErrorModal(
- message = errorMessage.orEmpty(),
- onDismiss = onBackWithProgress,
- )
- }
-
- if (pendingP2pSwitch != null) {
- P2pConsentDialog(
- onEnableP2p = {
- val pending = pendingP2pSwitch ?: return@P2pConsentDialog
- pendingP2pSwitch = null
- P2pSettingsRepository.setP2pEnabled(true)
- val episode = pending.episode
- if (episode != null) {
- switchToP2pEpisodeStream(
- stream = pending.stream,
- episode = episode,
- isAutoPlay = pending.isAutoPlay,
- )
- } else {
- switchToP2pSourceStream(pending.stream)
- }
- },
- onDismiss = {
- val pending = pendingP2pSwitch
- if (pending?.isAutoPlay == true) {
- nextEpisodeAutoPlaySearching = false
- nextEpisodeAutoPlayCountdown = null
- nextEpisodeAutoPlaySourceName = null
- }
- pendingP2pSwitch = null
- },
- )
- }
-
- AudioTrackModal(
- visible = showAudioModal,
- audioTracks = audioTracks,
- selectedIndex = selectedAudioIndex,
- onTrackSelected = { index ->
- selectedAudioIndex = index
- persistAudioPreference(audioTracks.firstOrNull { it.index == index })
- playerController?.selectAudioTrack(index)
- scope.launch {
- delay(200)
- showAudioModal = false
- }
- },
- onDismiss = { showAudioModal = false },
- )
-
- SubtitleModal(
- visible = showSubtitleModal,
- activeTab = activeSubtitleTab,
- subtitleTracks = subtitleTracks,
- selectedSubtitleIndex = selectedSubtitleIndex,
- addonSubtitles = visibleAddonSubtitles,
- selectedAddonSubtitleId = selectedAddonSubtitleId,
- isLoadingAddonSubtitles = isLoadingAddonSubtitles,
- subtitleStyle = subtitleStyle,
- subtitleDelayMs = subtitleDelayMs,
- selectedAddonSubtitle = selectedAddonSubtitle,
- subtitleAutoSyncState = subtitleAutoSyncState,
- onTabSelected = { activeSubtitleTab = it },
- onBuiltInTrackSelected = { index ->
- val wasCustom = useCustomSubtitles
- selectedSubtitleIndex = index
- selectedAddonSubtitleId = null
- useCustomSubtitles = false
- persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index })
- if (wasCustom) {
- playerController?.clearExternalSubtitleAndSelect(index)
- } else {
- playerController?.selectSubtitleTrack(index)
- }
- },
- onAddonSubtitleSelected = { addon ->
- selectedAddonSubtitleId = addon.id
- selectedSubtitleIndex = -1
- useCustomSubtitles = true
- persistAddonSubtitlePreference(addon)
- playerController?.setSubtitleUri(addon.url)
- },
- onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem,
- onStyleChanged = PlayerSettingsRepository::setSubtitleStyle,
- onSubtitleDelayChanged = ::setSubtitleDelay,
- onSubtitleDelayReset = { setSubtitleDelay(0) },
- onAutoSyncCapture = ::captureSubtitleAutoSyncTime,
- onAutoSyncCueSelected = ::applySubtitleAutoSyncCue,
- onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) },
- onDismiss = { showSubtitleModal = false },
- )
-
- IosVideoSettingsModal(
- visible = showVideoSettingsModal,
- settings = playerSettingsUiState,
- onSettingsChanged = {
- playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value)
- },
- onDismiss = { showVideoSettingsModal = false },
- )
-
- // Sources Panel
- PlayerSourcesPanel(
- visible = showSourcesPanel,
- streamsUiState = sourceStreamsState,
- currentStreamUrl = activeSourceUrl,
- currentStreamName = activeStreamTitle,
- onFilterSelected = { PlayerStreamsRepository.selectSourceFilter(it) },
- onStreamSelected = ::switchToSource,
- onReload = {
- val type = contentType ?: parentMetaType
- val vid = activeVideoId ?: return@PlayerSourcesPanel
- PlayerStreamsRepository.loadSources(
- type = type,
- videoId = vid,
- season = activeSeasonNumber,
- episode = activeEpisodeNumber,
- forceRefresh = true,
- )
- },
- onDismiss = {
- showSourcesPanel = false
- controlsVisible = true
- },
- )
-
- // Episodes Panel
- if (isSeries) {
- PlayerEpisodesPanel(
- visible = showEpisodesPanel,
- episodes = allEpisodes,
- parentMetaType = parentMetaType,
- parentMetaId = parentMetaId,
- currentSeason = activeSeasonNumber,
- currentEpisode = activeEpisodeNumber,
- progressByVideoId = watchProgressUiState.byVideoId,
- watchedKeys = watchedUiState.watchedKeys,
- blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
- episodeStreamsState = episodeStreamsPanelState.copy(
- streamsUiState = episodeStreamsRepoState,
- ),
- onSeasonSelected = { /* season tab change handled internally */ },
- onEpisodeSelected = { episode ->
- val downloadedEpisode = DownloadsRepository.findPlayableDownload(
- parentMetaId = parentMetaId,
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- videoId = episode.id,
- )
- if (downloadedEpisode != null) {
- switchToDownloadedEpisode(downloadedEpisode, episode)
- return@PlayerEpisodesPanel
- }
-
- val type = contentType ?: parentMetaType
- PlayerStreamsRepository.loadEpisodeStreams(
- type = type,
- videoId = episode.id,
- season = episode.season,
- episode = episode.episode,
- )
- episodeStreamsPanelState = EpisodeStreamsPanelState(
- showStreams = true,
- selectedEpisode = episode,
- )
- },
- onEpisodeStreamFilterSelected = {
- PlayerStreamsRepository.selectEpisodeStreamsFilter(it)
- },
- onEpisodeStreamSelected = ::switchToEpisodeStream,
- onBackToEpisodes = {
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- PlayerStreamsRepository.clearEpisodeStreams()
- },
- onReloadEpisodeStreams = {
- val episode = episodeStreamsPanelState.selectedEpisode ?: return@PlayerEpisodesPanel
- val type = contentType ?: parentMetaType
- PlayerStreamsRepository.loadEpisodeStreams(
- type = type,
- videoId = episode.id,
- season = episode.season,
- episode = episode.episode,
- forceRefresh = true,
- )
- },
- onDismiss = {
- showEpisodesPanel = false
- episodeStreamsPanelState = EpisodeStreamsPanelState()
- PlayerStreamsRepository.clearEpisodeStreams()
- controlsVisible = true
- },
- )
- }
-
- val season = activeSeasonNumber
- val episode = activeEpisodeNumber
- val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
- ?: parentMetaId.takeIf { it.startsWith("tt") }
- ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
-
- if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
- com.nuvio.app.features.player.skip.SubmitIntroDialog(
- imdbId = imdbId,
- season = season,
- episode = episode,
- currentTimeSec = (displayedPositionMs / 1000.0),
- segmentType = submitIntroSegmentType,
- onSegmentTypeChange = { submitIntroSegmentType = it },
- startTimeStr = submitIntroStartTimeStr,
- onStartTimeChange = { submitIntroStartTimeStr = it },
- endTimeStr = submitIntroEndTimeStr,
- onEndTimeChange = { submitIntroEndTimeStr = it },
- onDismiss = { showSubmitIntroModal = false },
- onSuccess = {
- submitIntroStartTimeStr = "00:00"
- submitIntroEndTimeStr = "00:00"
- submitIntroSegmentType = "intro"
- showSubmitIntroModal = false
- }
- )
- }
- }
- }
-}
-
-private fun buildAddonSubtitleFetchKey(
- addons: List,
- type: String?,
- videoId: String?,
-): String? {
- val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null
- val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null
- val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon ->
- val manifest = addon.manifest ?: return@mapNotNull null
- val supportsSubtitles = manifest.resources.any { resource ->
- resource.isCompatibleSubtitleResource(
- type = normalizedType,
- videoId = normalizedVideoId,
- )
- }
- if (!supportsSubtitles) return@mapNotNull null
- "${manifest.id}:${manifest.transportUrl}"
- }
-
- if (compatibleSubtitleAddons.isEmpty()) return null
- return buildString {
- append(normalizedType)
- append('|')
- append(normalizedVideoId)
- append('|')
- append(compatibleSubtitleAddons.sorted().joinToString("|"))
- }
-}
-
-private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean {
- val isSubtitleResource = name.equals("subtitles", ignoreCase = true) ||
- name.equals("subtitle", ignoreCase = true)
- if (!isSubtitleResource) return false
-
- val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type
- val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) }
- if (!typeMatches) return false
-
- return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
-}
-
-private fun findPreferredTrackIndex(
- tracks: List,
- targets: List,
- language: (T) -> String?,
-): Int {
- if (targets.isEmpty()) return -1
- for (target in targets) {
- val matchIndex = tracks.indexOfFirst { track ->
- languageMatchesPreference(
- trackLanguage = language(track),
- targetLanguage = target,
- )
- }
- if (matchIndex >= 0) {
- return matchIndex
- }
- }
- return -1
-}
-
-private fun findPreferredSubtitleTrackIndex(
- tracks: List,
- targets: List,
-): Int {
- if (targets.isEmpty()) return -1
-
- for ((targetPosition, target) in targets.withIndex()) {
- val normalizedTarget = normalizeLanguageCode(target) ?: continue
- if (normalizedTarget == SubtitleLanguageOption.FORCED) {
- val forcedIndex = tracks.indexOfFirst { it.isForced }
- if (forcedIndex >= 0) return forcedIndex
- if (targetPosition == 0) return -1
- continue
- }
-
- val matchIndex = tracks.indexOfFirst { track ->
- languageMatchesPreference(
- trackLanguage = track.language,
- targetLanguage = normalizedTarget,
- )
- }
- if (matchIndex >= 0) return matchIndex
- }
-
- return -1
-}
-
-private fun filterAddonSubtitlesForSettings(
- subtitles: List,
- settings: PlayerSettingsUiState,
- selectedAddonSubtitleId: String?,
-): List {
- val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
- settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
- if (!shouldFilter) return subtitles
-
- val targets = preferredSubtitleTargetsForSettings(settings)
- if (targets.isEmpty()) {
- return subtitles.filter { subtitle ->
- subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
- }
- }
-
- val filtered = subtitles.filter { subtitle ->
- subtitle.id == selectedAddonSubtitleId ||
- subtitle.url == selectedAddonSubtitleId ||
- targets.any { target ->
- languageMatchesPreference(
- trackLanguage = subtitle.language,
- targetLanguage = target,
- )
- }
- }
- return filtered
-}
-
-private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List {
- val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
- SubtitleLanguageOption.FORCED
- } else {
- settings.preferredSubtitleLanguage
- }
- return resolvePreferredSubtitleLanguageTargets(
- preferredSubtitleLanguage = preferredLanguage,
- secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
- deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
- ).filterNot { it == SubtitleLanguageOption.FORCED }
-}
-
-private fun findPersistedAudioTrackIndex(
- tracks: List,
- preference: PersistedPlayerTrackPreference,
-): Int {
- preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
- tracks.firstOrNull { it.id == trackId }?.let { return it.index }
- }
- preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language ->
- tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
- }
- preference.audioName?.takeIf { it.isNotBlank() }?.let { name ->
- tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
- }
- return -1
-}
-
-private fun findPersistedSubtitleTrackIndex(
- tracks: List,
- preference: PersistedPlayerTrackPreference,
-): Int {
- preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
- tracks.firstOrNull { it.id == trackId }?.let { return it.index }
- }
- preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language ->
- tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
- }
- preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name ->
- tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
- }
- return -1
+ )
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt
new file mode 100644
index 00000000..47921c81
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt
@@ -0,0 +1,38 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.ui.Modifier
+
+internal data class PlayerScreenArgs(
+ val title: String,
+ val sourceUrl: String,
+ val sourceAudioUrl: String?,
+ val sourceHeaders: Map,
+ val sourceResponseHeaders: Map,
+ val providerName: String,
+ val streamTitle: String,
+ val streamSubtitle: String?,
+ val initialBingeGroup: String?,
+ val pauseDescription: String?,
+ val onBack: () -> Unit,
+ val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?,
+ val modifier: Modifier,
+ val logo: String?,
+ val poster: String?,
+ val background: String?,
+ val seasonNumber: Int?,
+ val episodeNumber: Int?,
+ val episodeTitle: String?,
+ val episodeThumbnail: String?,
+ val contentType: String?,
+ val videoId: String?,
+ val parentMetaId: String,
+ val parentMetaType: String,
+ val providerAddonId: String?,
+ val torrentInfoHash: String?,
+ val torrentFileIdx: Int?,
+ val torrentFilename: String?,
+ val torrentMagnetUri: String?,
+ val torrentTrackers: List,
+ val initialPositionMs: Long,
+ val initialProgressFraction: Float?,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt
new file mode 100644
index 00000000..bc1363e5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt
@@ -0,0 +1,146 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.details.MetaDetailsRepository
+import com.nuvio.app.features.details.MetaScreenSettingsRepository
+import com.nuvio.app.features.p2p.P2pSettingsRepository
+import com.nuvio.app.features.p2p.P2pStreamingEngine
+import com.nuvio.app.features.watched.WatchedRepository
+import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.compose_player_airs_prefix
+import nuvio.composeapp.generated.resources.compose_player_downloaded
+import nuvio.composeapp.generated.resources.compose_player_resize_fill
+import nuvio.composeapp.generated.resources.compose_player_resize_fit
+import nuvio.composeapp.generated.resources.compose_player_resize_zoom
+import nuvio.composeapp.generated.resources.generic_unknown
+import nuvio.composeapp.generated.resources.parental_alcohol
+import nuvio.composeapp.generated.resources.parental_frightening
+import nuvio.composeapp.generated.resources.parental_nudity
+import nuvio.composeapp.generated.resources.parental_profanity
+import nuvio.composeapp.generated.resources.parental_severity_mild
+import nuvio.composeapp.generated.resources.parental_severity_moderate
+import nuvio.composeapp.generated.resources.parental_severity_severe
+import nuvio.composeapp.generated.resources.parental_violence
+import nuvio.composeapp.generated.resources.compose_player_tba
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+internal fun PlayerScreenContent(args: PlayerScreenArgs) {
+ LockPlayerToLandscape()
+
+ val playerSettingsUiState by remember {
+ PlayerSettingsRepository.ensureLoaded()
+ PlayerSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val p2pSettingsUiState by remember {
+ P2pSettingsRepository.ensureLoaded()
+ P2pSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle()
+ val metaScreenSettingsUiState by remember {
+ MetaScreenSettingsRepository.ensureLoaded()
+ MetaScreenSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val watchedUiState by remember {
+ WatchedRepository.ensureLoaded()
+ WatchedRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val watchProgressUiState by remember {
+ WatchProgressRepository.ensureLoaded()
+ WatchProgressRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
+ val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
+ val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
+ val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
+ val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
+ val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
+
+ val runtime = remember { PlayerScreenRuntime(args) }
+ runtime.args = args
+
+ BoxWithConstraints(
+ modifier = args.modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
+ val density = LocalDensity.current
+ val horizontalSafePadding = playerHorizontalSafePadding()
+ val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) }
+
+ runtime.scope = rememberCoroutineScope()
+ runtime.hapticFeedback = LocalHapticFeedback.current
+ runtime.gestureController = rememberPlayerGestureController()
+ runtime.playerSettingsUiState = playerSettingsUiState
+ runtime.p2pSettingsUiState = p2pSettingsUiState
+ runtime.p2pStreamingState = p2pStreamingState
+ runtime.metaScreenSettingsUiState = metaScreenSettingsUiState
+ runtime.watchedUiState = watchedUiState
+ runtime.watchProgressUiState = watchProgressUiState
+ runtime.sourceStreamsState = sourceStreamsState
+ runtime.episodeStreamsRepoState = episodeStreamsRepoState
+ runtime.metaUiState = metaUiState
+ runtime.addonsUiState = addonsUiState
+ runtime.addonSubtitles = addonSubtitles
+ runtime.isLoadingAddonSubtitles = isLoadingAddonSubtitles
+ runtime.horizontalSafePadding = horizontalSafePadding
+ runtime.metrics = metrics
+ runtime.sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding
+ runtime.overlayBottomPadding = sliderOverlayBottomPadding(metrics)
+ runtime.sideGestureSystemEdgeExclusionPx = with(density) {
+ PlayerSideGestureSystemEdgeExclusion.toPx()
+ }
+ runtime.resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit)
+ runtime.resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill)
+ runtime.resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom)
+ runtime.downloadedLabel = stringResource(Res.string.compose_player_downloaded)
+ runtime.airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
+ runtime.tbaLabel = stringResource(Res.string.compose_player_tba)
+ runtime.genericUnknownLabel = stringResource(Res.string.generic_unknown)
+ runtime.parentalGuideLabels = ParentalGuideLabels(
+ nudity = stringResource(Res.string.parental_nudity),
+ violence = stringResource(Res.string.parental_violence),
+ profanity = stringResource(Res.string.parental_profanity),
+ alcohol = stringResource(Res.string.parental_alcohol),
+ frightening = stringResource(Res.string.parental_frightening),
+ severe = stringResource(Res.string.parental_severity_severe),
+ moderate = stringResource(Res.string.parental_severity_moderate),
+ mild = stringResource(Res.string.parental_severity_mild),
+ )
+ if (runtime.playerMetaVideos.isEmpty()) {
+ runtime.playerMetaVideos = MetaDetailsRepository.peek(
+ args.parentMetaType,
+ args.parentMetaId,
+ )?.videos ?: emptyList()
+ }
+ if (runtime.lastSyncedSettingsResizeMode != playerSettingsUiState.resizeMode) {
+ runtime.resizeMode = playerSettingsUiState.resizeMode
+ runtime.lastSyncedSettingsResizeMode = playerSettingsUiState.resizeMode
+ }
+ runtime.resetIdentityStateIfNeeded()
+
+ val keepScreenAwake = runtime.errorMessage == null &&
+ (runtime.playbackSnapshot.isPlaying ||
+ (runtime.shouldPlay && runtime.playbackSnapshot.isLoading))
+ EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake)
+ ManagePlayerPictureInPicture(
+ isPlaying = runtime.playbackSnapshot.isPlaying,
+ playerSize = runtime.layoutSize,
+ )
+ runtime.BindPlayerRuntimeEffects()
+ runtime.RenderPlayerRuntimeUi()
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt
new file mode 100644
index 00000000..1e7dc31f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt
@@ -0,0 +1,236 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.runtime.Composable
+import com.nuvio.app.features.details.MetaDetailsUiState
+import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.downloads.DownloadsRepository
+import com.nuvio.app.features.p2p.P2pConsentDialog
+import com.nuvio.app.features.p2p.P2pSettingsRepository
+import com.nuvio.app.features.streams.StreamItem
+import com.nuvio.app.features.streams.StreamsUiState
+import com.nuvio.app.features.watchprogress.WatchProgressEntry
+
+@Composable
+internal fun PlayerScreenModalHosts(
+ pendingP2pSwitch: PendingPlayerP2pSwitch?,
+ onPendingP2pSwitchChanged: (PendingPlayerP2pSwitch?) -> Unit,
+ onP2pEpisodeStreamSelected: (StreamItem, MetaVideo, Boolean) -> Unit,
+ onP2pSourceStreamSelected: (StreamItem) -> Unit,
+ onNextEpisodeAutoPlaySearchingChanged: (Boolean) -> Unit,
+ onNextEpisodeAutoPlayCountdownChanged: (Int?) -> Unit,
+ onNextEpisodeAutoPlaySourceNameChanged: (String?) -> Unit,
+ showAudioModal: Boolean,
+ audioTracks: List,
+ selectedAudioIndex: Int,
+ onAudioTrackSelected: (Int) -> Unit,
+ onAudioModalDismissed: () -> Unit,
+ showSubtitleModal: Boolean,
+ activeSubtitleTab: SubtitleTab,
+ subtitleTracks: List,
+ selectedSubtitleIndex: Int,
+ addonSubtitles: List,
+ selectedAddonSubtitleId: String?,
+ isLoadingAddonSubtitles: Boolean,
+ subtitleStyle: SubtitleStyleState,
+ subtitleDelayMs: Int,
+ selectedAddonSubtitle: AddonSubtitle?,
+ subtitleAutoSyncState: SubtitleAutoSyncUiState,
+ onSubtitleTabSelected: (SubtitleTab) -> Unit,
+ onBuiltInSubtitleTrackSelected: (Int) -> Unit,
+ onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
+ onFetchAddonSubtitles: () -> Unit,
+ onSubtitleStyleChanged: (SubtitleStyleState) -> Unit,
+ onSubtitleDelayChanged: (Int) -> Unit,
+ onSubtitleDelayReset: () -> Unit,
+ onAutoSyncCapture: () -> Unit,
+ onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
+ onAutoSyncReload: () -> Unit,
+ onSubtitleModalDismissed: () -> Unit,
+ showVideoSettingsModal: Boolean,
+ playerSettings: PlayerSettingsUiState,
+ onVideoSettingsChanged: () -> Unit,
+ onVideoSettingsModalDismissed: () -> Unit,
+ showSourcesPanel: Boolean,
+ sourceStreamsState: StreamsUiState,
+ activeSourceUrl: String,
+ activeStreamTitle: String,
+ onSourceFilterSelected: (String?) -> Unit,
+ onSourceStreamSelected: (StreamItem) -> Unit,
+ onReloadSources: () -> Unit,
+ onSourcesPanelDismissed: () -> Unit,
+ isSeries: Boolean,
+ showEpisodesPanel: Boolean,
+ allEpisodes: List,
+ parentMetaType: String,
+ parentMetaId: String,
+ activeSeasonNumber: Int?,
+ activeEpisodeNumber: Int?,
+ watchProgressByVideoId: Map,
+ watchedKeys: Set,
+ blurUnwatchedEpisodes: Boolean,
+ episodeStreamsPanelState: EpisodeStreamsPanelState,
+ episodeStreamsRepoState: StreamsUiState,
+ onEpisodeSelectedForDownload: (MetaVideo) -> Boolean,
+ onEpisodeStreamsRequested: (MetaVideo) -> Unit,
+ onEpisodeStreamFilterSelected: (String?) -> Unit,
+ onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit,
+ onBackToEpisodes: () -> Unit,
+ onReloadEpisodeStreams: () -> Unit,
+ onEpisodesPanelDismissed: () -> Unit,
+ showSubmitIntroModal: Boolean,
+ activeVideoId: String?,
+ metaUiState: MetaDetailsUiState,
+ displayedPositionMs: Long,
+ submitIntroSegmentType: String,
+ onSubmitIntroSegmentTypeChanged: (String) -> Unit,
+ submitIntroStartTimeStr: String,
+ onSubmitIntroStartTimeChanged: (String) -> Unit,
+ submitIntroEndTimeStr: String,
+ onSubmitIntroEndTimeChanged: (String) -> Unit,
+ onSubmitIntroDismissed: () -> Unit,
+ onSubmitIntroSuccess: () -> Unit,
+) {
+ if (pendingP2pSwitch != null) {
+ P2pConsentDialog(
+ onEnableP2p = {
+ val pending = pendingP2pSwitch
+ onPendingP2pSwitchChanged(null)
+ P2pSettingsRepository.setP2pEnabled(true)
+ val episode = pending.episode
+ if (episode != null) {
+ onP2pEpisodeStreamSelected(pending.stream, episode, pending.isAutoPlay)
+ } else {
+ onP2pSourceStreamSelected(pending.stream)
+ }
+ },
+ onDismiss = {
+ if (pendingP2pSwitch.isAutoPlay) {
+ onNextEpisodeAutoPlaySearchingChanged(false)
+ onNextEpisodeAutoPlayCountdownChanged(null)
+ onNextEpisodeAutoPlaySourceNameChanged(null)
+ }
+ onPendingP2pSwitchChanged(null)
+ },
+ )
+ }
+
+ AudioTrackModal(
+ visible = showAudioModal,
+ audioTracks = audioTracks,
+ selectedIndex = selectedAudioIndex,
+ onTrackSelected = onAudioTrackSelected,
+ onDismiss = onAudioModalDismissed,
+ )
+
+ SubtitleModal(
+ visible = showSubtitleModal,
+ activeTab = activeSubtitleTab,
+ subtitleTracks = subtitleTracks,
+ selectedSubtitleIndex = selectedSubtitleIndex,
+ addonSubtitles = addonSubtitles,
+ selectedAddonSubtitleId = selectedAddonSubtitleId,
+ isLoadingAddonSubtitles = isLoadingAddonSubtitles,
+ subtitleStyle = subtitleStyle,
+ subtitleDelayMs = subtitleDelayMs,
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ subtitleAutoSyncState = subtitleAutoSyncState,
+ onTabSelected = onSubtitleTabSelected,
+ onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected,
+ onAddonSubtitleSelected = onAddonSubtitleSelected,
+ onFetchAddonSubtitles = onFetchAddonSubtitles,
+ onStyleChanged = onSubtitleStyleChanged,
+ onSubtitleDelayChanged = onSubtitleDelayChanged,
+ onSubtitleDelayReset = onSubtitleDelayReset,
+ onAutoSyncCapture = onAutoSyncCapture,
+ onAutoSyncCueSelected = onAutoSyncCueSelected,
+ onAutoSyncReload = onAutoSyncReload,
+ onDismiss = onSubtitleModalDismissed,
+ )
+
+ IosVideoSettingsModal(
+ visible = showVideoSettingsModal,
+ settings = playerSettings,
+ onSettingsChanged = onVideoSettingsChanged,
+ onDismiss = onVideoSettingsModalDismissed,
+ )
+
+ PlayerSourcesPanel(
+ visible = showSourcesPanel,
+ streamsUiState = sourceStreamsState,
+ currentStreamUrl = activeSourceUrl,
+ currentStreamName = activeStreamTitle,
+ onFilterSelected = onSourceFilterSelected,
+ onStreamSelected = onSourceStreamSelected,
+ onReload = onReloadSources,
+ onDismiss = onSourcesPanelDismissed,
+ )
+
+ if (isSeries) {
+ PlayerEpisodesPanel(
+ visible = showEpisodesPanel,
+ episodes = allEpisodes,
+ parentMetaType = parentMetaType,
+ parentMetaId = parentMetaId,
+ currentSeason = activeSeasonNumber,
+ currentEpisode = activeEpisodeNumber,
+ progressByVideoId = watchProgressByVideoId,
+ watchedKeys = watchedKeys,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
+ episodeStreamsState = episodeStreamsPanelState.copy(
+ streamsUiState = episodeStreamsRepoState,
+ ),
+ onSeasonSelected = { },
+ onEpisodeSelected = { episode ->
+ if (!onEpisodeSelectedForDownload(episode)) {
+ onEpisodeStreamsRequested(episode)
+ }
+ },
+ onEpisodeStreamFilterSelected = onEpisodeStreamFilterSelected,
+ onEpisodeStreamSelected = onEpisodeStreamSelected,
+ onBackToEpisodes = onBackToEpisodes,
+ onReloadEpisodeStreams = onReloadEpisodeStreams,
+ onDismiss = onEpisodesPanelDismissed,
+ )
+ }
+
+ val season = activeSeasonNumber
+ val episode = activeEpisodeNumber
+ val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
+ ?: parentMetaId.takeIf { it.startsWith("tt") }
+ ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
+
+ if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
+ com.nuvio.app.features.player.skip.SubmitIntroDialog(
+ imdbId = imdbId,
+ season = season,
+ episode = episode,
+ currentTimeSec = displayedPositionMs / 1000.0,
+ segmentType = submitIntroSegmentType,
+ onSegmentTypeChange = onSubmitIntroSegmentTypeChanged,
+ startTimeStr = submitIntroStartTimeStr,
+ onStartTimeChange = onSubmitIntroStartTimeChanged,
+ endTimeStr = submitIntroEndTimeStr,
+ onEndTimeChange = onSubmitIntroEndTimeChanged,
+ onDismiss = onSubmitIntroDismissed,
+ onSuccess = onSubmitIntroSuccess,
+ )
+ }
+}
+
+internal fun selectDownloadedEpisodeForPlayback(
+ parentMetaId: String,
+ episode: MetaVideo,
+ onDownloadedEpisodeSelected: (com.nuvio.app.features.downloads.DownloadItem, MetaVideo) -> Unit,
+): Boolean {
+ val downloadedEpisode = DownloadsRepository.findPlayableDownload(
+ parentMetaId = parentMetaId,
+ seasonNumber = episode.season,
+ episodeNumber = episode.episode,
+ videoId = episode.id,
+ )
+ if (downloadedEpisode != null) {
+ onDownloadedEpisodeSelected(downloadedEpisode, episode)
+ return true
+ }
+ return false
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt
new file mode 100644
index 00000000..9c539efa
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt
@@ -0,0 +1,471 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import com.nuvio.app.features.details.MetaDetailsRepository
+import com.nuvio.app.features.p2p.P2pSettingsRepository
+import com.nuvio.app.features.p2p.P2pStreamRequest
+import com.nuvio.app.features.p2p.P2pStreamingEngine
+import com.nuvio.app.features.p2p.P2pStreamingState
+import com.nuvio.app.features.player.skip.NextEpisodeInfo
+import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
+import com.nuvio.app.features.player.skip.SkipIntroRepository
+import com.nuvio.app.features.streams.BingeGroupCacheRepository
+import com.nuvio.app.features.streams.StreamLinkCacheRepository
+import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nuvio.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.getString
+
+@Composable
+internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
+ val currentFeedback = liveGestureFeedback ?: gestureFeedback
+ LaunchedEffect(currentFeedback) {
+ if (currentFeedback != null) {
+ renderedGestureFeedback = currentFeedback
+ }
+ }
+
+ LaunchedEffect(parentMetaType, parentMetaId) {
+ playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList()
+ if (playerMetaVideos.isEmpty()) {
+ playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
+ }
+ }
+
+ LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) {
+ val currentMeta = metaUiState.meta ?: return@LaunchedEffect
+ if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) {
+ playerMetaVideos = currentMeta.videos
+ }
+ }
+
+ LaunchedEffect(currentStreamBingeGroup, parentMetaId) {
+ val bg = currentStreamBingeGroup
+ if (bg != null && parentMetaId.isNotBlank()) {
+ BingeGroupCacheRepository.save(parentMetaId, bg)
+ }
+ }
+
+ LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
+ errorMessage = null
+ playerController = null
+ playerControllerSourceUrl = null
+ playbackSnapshot = PlayerPlaybackSnapshot()
+ isScrubbingTimeline = false
+ scrubbingPositionMs = null
+ liveGestureFeedback = null
+ renderedGestureFeedback = null
+ lockedOverlayVisible = false
+ initialLoadCompleted = false
+ lastProgressPersistEpochMs = 0L
+ previousIsPlaying = false
+ pendingScrobbleStartAfterSeek = false
+ seekProgressSyncJob?.cancel()
+ seekProgressSyncJob = null
+ accumulatedSeekResetJob?.cancel()
+ accumulatedSeekResetJob = null
+ accumulatedSeekState = null
+ speedBoostRestoreSpeed = null
+ preferredAudioSelectionApplied = false
+ preferredSubtitleSelectionApplied = false
+ showSourcesPanel = false
+ showEpisodesPanel = false
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ PlayerStreamsRepository.clearEpisodeStreams()
+ SubtitleRepository.clear()
+ WatchProgressRepository.ensureLoaded()
+ }
+
+ LaunchedEffect(
+ activeTorrentInfoHash,
+ activeTorrentFileIdx,
+ activeTorrentFilename,
+ activeTorrentMagnetUri,
+ activeTorrentTrackers,
+ p2pSettingsUiState.p2pEnabled,
+ ) {
+ val infoHash = activeTorrentInfoHash
+ if (infoHash == null) {
+ p2pResolvedSourceUrl = null
+ P2pStreamingEngine.stopStream()
+ return@LaunchedEffect
+ }
+ if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) {
+ return@LaunchedEffect
+ }
+
+ p2pResolvedSourceUrl = null
+ val requestedFileIdx = activeTorrentFileIdx
+ val requestedFilename = activeTorrentFilename
+ val requestedMagnetUri = activeTorrentMagnetUri
+ val requestedTrackers = activeTorrentTrackers
+ errorMessage = null
+ playerController = null
+ playerControllerSourceUrl = null
+ playbackSnapshot = PlayerPlaybackSnapshot()
+ initialLoadCompleted = false
+
+ try {
+ val localUrl = P2pStreamingEngine.startStream(
+ P2pStreamRequest(
+ infoHash = infoHash,
+ fileIdx = requestedFileIdx,
+ filename = requestedFilename,
+ magnetUri = requestedMagnetUri,
+ trackers = requestedTrackers,
+ ),
+ )
+ if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) {
+ activeSourceAudioUrl = null
+ activeSourceHeaders = emptyMap()
+ activeSourceResponseHeaders = emptyMap()
+ p2pResolvedSourceUrl = localUrl
+ }
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ errorMessage = getString(
+ Res.string.player_error_failed_start_torrent,
+ error.message ?: genericUnknownLabel,
+ )
+ controlsVisible = !playerControlsLocked
+ initialLoadCompleted = true
+ }
+ }
+
+ LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) {
+ val state = p2pStreamingState
+ if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) {
+ errorMessage = getString(Res.string.player_error_torrent, state.message)
+ controlsVisible = !playerControlsLocked
+ }
+ }
+
+ LaunchedEffect(playbackSession.videoId) {
+ subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0
+ subtitleAutoSyncState = SubtitleAutoSyncUiState()
+ }
+
+ LaunchedEffect(playerController, subtitleDelayMs) {
+ playerController?.setSubtitleDelayMs(subtitleDelayMs)
+ }
+
+ LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) {
+ subtitleAutoSyncState = SubtitleAutoSyncUiState()
+ }
+
+ LaunchedEffect(playerController, subtitleStyle) {
+ playerController?.applySubtitleStyle(subtitleStyle)
+ }
+
+ LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
+ val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
+ if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
+ return@LaunchedEffect
+ }
+ if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
+ autoFetchedAddonSubtitlesForKey = fetchKey
+ fetchAddonSubtitlesForActiveItem()
+ }
+
+ LaunchedEffect(playbackSnapshot.isLoading, playerController) {
+ if (!playbackSnapshot.isLoading && playerController != null) {
+ refreshTracks()
+ }
+ }
+
+ LaunchedEffect(
+ playerController,
+ playbackSnapshot.isLoading,
+ preferredAudioSelectionApplied,
+ preferredSubtitleSelectionApplied,
+ ) {
+ if (playerController == null || playbackSnapshot.isLoading) {
+ return@LaunchedEffect
+ }
+ if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
+ return@LaunchedEffect
+ }
+
+ repeat(10) {
+ refreshTracks()
+ if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
+ return@LaunchedEffect
+ }
+ delay(300)
+ }
+ }
+
+ LaunchedEffect(
+ playerController,
+ playerControllerSourceUrl,
+ playbackSnapshot.isLoading,
+ playbackSnapshot.durationMs,
+ activeInitialPositionMs,
+ activeInitialProgressFraction,
+ initialSeekApplied,
+ ) {
+ val controller = playerController ?: return@LaunchedEffect
+ if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect
+ if (initialSeekApplied || playbackSnapshot.isLoading) return@LaunchedEffect
+
+ val progressFraction = activeInitialProgressFraction
+ ?.takeIf { it > 0f }
+ ?.coerceIn(0f, 1f)
+ val targetPositionMs = when {
+ activeInitialPositionMs > 0L -> activeInitialPositionMs
+ progressFraction != null && playbackSnapshot.durationMs > 0L -> {
+ (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong()
+ }
+ progressFraction != null -> return@LaunchedEffect
+ else -> 0L
+ }
+ if (targetPositionMs <= 0L) {
+ initialSeekApplied = true
+ return@LaunchedEffect
+ }
+
+ controller.seekTo(targetPositionMs)
+ initialSeekApplied = true
+ }
+
+ BindPlayerUiVisibilityEffects()
+ BindPlayerMetadataAndSkipEffects()
+
+ DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
+ onDispose {
+ flushWatchProgress()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ P2pStreamingEngine.shutdown()
+ PlayerStreamsRepository.clearAll()
+ }
+ }
+}
+
+@Composable
+private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() {
+ LaunchedEffect(
+ controlsVisible,
+ isScrubbingTimeline,
+ playbackSnapshot.isPlaying,
+ playbackSnapshot.isLoading,
+ showParentalGuide,
+ errorMessage,
+ ) {
+ if (
+ !controlsVisible ||
+ isScrubbingTimeline ||
+ !playbackSnapshot.isPlaying ||
+ playbackSnapshot.isLoading ||
+ showParentalGuide ||
+ errorMessage != null
+ ) {
+ return@LaunchedEffect
+ }
+ delay(3500)
+ controlsVisible = false
+ }
+
+ LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
+ if (!playerControlsLocked || !lockedOverlayVisible) return@LaunchedEffect
+ delay(PlayerLockedOverlayDurationMs)
+ lockedOverlayVisible = false
+ }
+
+ LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
+ pausedOverlayVisible = false
+ if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
+ return@LaunchedEffect
+ }
+ delay(5000)
+ pausedOverlayVisible = true
+ }
+
+ LaunchedEffect(
+ playbackSnapshot.positionMs,
+ playbackSnapshot.isPlaying,
+ playbackSnapshot.isLoading,
+ playbackSnapshot.isEnded,
+ playbackSnapshot.durationMs,
+ ) {
+ if (playbackSnapshot.isEnded) {
+ flushWatchProgress()
+ previousIsPlaying = false
+ pendingScrobbleStartAfterSeek = false
+ return@LaunchedEffect
+ }
+
+ if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
+ pendingScrobbleStartAfterSeek = false
+ flushWatchProgress()
+ }
+
+ if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
+ pendingScrobbleStartAfterSeek = false
+ emitTraktScrobbleStart()
+ } else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
+ emitTraktScrobbleStart()
+ }
+
+ if (!playbackSnapshot.isLoading) {
+ previousIsPlaying = playbackSnapshot.isPlaying
+ }
+ if (playbackSnapshot.isPlaying) {
+ persistPlaybackProgressTick()
+ }
+ }
+}
+
+@Composable
+private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() {
+ LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) {
+ parentalWarnings = emptyList()
+ showParentalGuide = false
+ parentalGuideHasShown = false
+ playbackStartedForParentalGuide = false
+
+ val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect
+ val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect
+ parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels)
+
+ if (playbackSnapshot.isPlaying) {
+ tryShowParentalGuide()
+ }
+ }
+
+ LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) {
+ if (playbackSnapshot.isPlaying) {
+ tryShowParentalGuide()
+ }
+ }
+
+ LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
+ skipIntervals = emptyList()
+ activeSkipInterval = null
+ skipIntervalDismissed = false
+ showNextEpisodeCard = false
+ nextEpisodeAutoPlayJob?.cancel()
+ nextEpisodeAutoPlaySearching = false
+
+ val season = activeSeasonNumber
+ val episode = activeEpisodeNumber
+ val vid = activeVideoId
+ if (season == null || episode == null || vid == null) return@LaunchedEffect
+
+ launch {
+ val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") }
+ val intervals = SkipIntroRepository.getSkipIntervals(
+ imdbId = imdbId,
+ season = season,
+ episode = episode,
+ )
+ skipIntervals = intervals
+ }
+ }
+
+ LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) {
+ if (skipIntervals.isEmpty()) {
+ activeSkipInterval = null
+ return@LaunchedEffect
+ }
+ val positionSec = playbackSnapshot.positionMs / 1000.0
+ val current = skipIntervals.firstOrNull { interval ->
+ positionSec >= interval.startTime && positionSec < interval.endTime
+ }
+ if (current != activeSkipInterval) {
+ activeSkipInterval = current
+ if (current != null) skipIntervalDismissed = false
+ }
+ }
+
+ LaunchedEffect(playerMetaVideos, activeSeasonNumber, activeEpisodeNumber) {
+ if (!isSeries || playerMetaVideos.isEmpty()) {
+ nextEpisodeInfo = null
+ return@LaunchedEffect
+ }
+ val curSeason = activeSeasonNumber ?: return@LaunchedEffect
+ val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect
+ val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode(
+ videos = playerMetaVideos,
+ currentSeason = curSeason,
+ currentEpisode = curEpisode,
+ )
+ val nextSeason = nextVideo?.season
+ val nextEpisode = nextVideo?.episode
+ nextEpisodeInfo = if (nextVideo != null && nextSeason != null && nextEpisode != null) {
+ NextEpisodeInfo(
+ videoId = nextVideo.id,
+ season = nextSeason,
+ episode = nextEpisode,
+ title = nextVideo.title,
+ thumbnail = nextVideo.thumbnail,
+ overview = nextVideo.overview,
+ released = nextVideo.released,
+ hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
+ unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
+ "$airsPrefix ${nextVideo.released ?: tbaLabel}"
+ } else null,
+ )
+ } else null
+ }
+
+ LaunchedEffect(
+ playbackSnapshot.positionMs,
+ playbackSnapshot.durationMs,
+ nextEpisodeInfo,
+ skipIntervals,
+ playerSettingsUiState.nextEpisodeThresholdMode,
+ playerSettingsUiState.nextEpisodeThresholdPercent,
+ playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
+ ) {
+ if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) {
+ showNextEpisodeCard = false
+ return@LaunchedEffect
+ }
+ val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard(
+ positionMs = playbackSnapshot.positionMs,
+ durationMs = playbackSnapshot.durationMs,
+ skipIntervals = skipIntervals,
+ thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode,
+ thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent,
+ thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
+ )
+ if (shouldShow && !showNextEpisodeCard) {
+ showNextEpisodeCard = true
+ if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
+ playNextEpisode()
+ }
+ } else if (!shouldShow) {
+ showNextEpisodeCard = false
+ }
+ }
+
+ LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) {
+ if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) {
+ showNextEpisodeCard = true
+ if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
+ playNextEpisode()
+ }
+ }
+ }
+}
+
+internal fun PlayerScreenRuntime.removeFailedStreamFromCache() {
+ val currentVideoId = activeVideoId ?: return
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = contentType ?: parentMetaType,
+ videoId = currentVideoId,
+ parentMetaId = parentMetaId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ )
+ StreamLinkCacheRepository.remove(cacheKey)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt
new file mode 100644
index 00000000..5d025f5e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt
@@ -0,0 +1,310 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nuvio.composeapp.generated.resources.*
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+internal data class PlayerSurfaceGestureCallbacks(
+ val onSurfaceTap: State<(Offset) -> Unit>,
+ val onSurfaceDoubleTap: State<(Offset) -> Unit>,
+ val activateHoldToSpeed: State<() -> Unit>,
+ val deactivateHoldToSpeed: State<() -> Unit>,
+ val showHorizontalSeekPreview: State<(Long, Long) -> Unit>,
+ val showBrightnessFeedback: State<(Float) -> Unit>,
+ val showVolumeFeedback: State<(PlayerAudioLevel) -> Unit>,
+ val clearLiveGestureFeedback: State<() -> Unit>,
+ val revealLockedOverlay: State<() -> Unit>,
+ val isHoldToSpeedGestureActive: State,
+ val playerControlsLocked: State,
+ val currentPositionMs: State,
+ val currentDurationMs: State,
+ val commitHorizontalSeek: State<(Long) -> Unit>,
+)
+
+internal fun PlayerScreenRuntime.showGestureFeedback(feedback: GestureFeedbackState) {
+ gestureMessageJob?.cancel()
+ gestureFeedback = feedback
+ gestureMessageJob = scope.launch {
+ delay(900)
+ gestureFeedback = null
+ }
+}
+
+internal fun PlayerScreenRuntime.showGestureMessage(message: String) {
+ showGestureFeedback(GestureFeedbackState(message = message))
+}
+
+internal fun PlayerScreenRuntime.clearLiveGestureFeedback() {
+ liveGestureFeedback = null
+}
+
+internal fun PlayerScreenRuntime.revealLockedOverlay() {
+ controlsVisible = false
+ lockedOverlayVisible = true
+}
+
+internal fun PlayerScreenRuntime.lockPlayerControls() {
+ playerControlsLocked = true
+ controlsVisible = false
+ lockedOverlayVisible = false
+ pausedOverlayVisible = false
+ isScrubbingTimeline = false
+ scrubbingPositionMs = null
+ gestureMessageJob?.cancel()
+ gestureFeedback = null
+ liveGestureFeedback = null
+ renderedGestureFeedback = null
+ showAudioModal = false
+ showSubtitleModal = false
+ showVideoSettingsModal = false
+ showSourcesPanel = false
+ showEpisodesPanel = false
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ PlayerStreamsRepository.clearEpisodeStreams()
+}
+
+internal fun PlayerScreenRuntime.unlockPlayerControls() {
+ playerControlsLocked = false
+ lockedOverlayVisible = false
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
+ val seconds = amountMs / 1000L
+ if (seconds <= 0L) return
+ showGestureFeedback(
+ GestureFeedbackState(
+ messageRes = if (direction == PlayerSeekDirection.Forward) {
+ Res.string.compose_player_seek_feedback_forward
+ } else {
+ Res.string.compose_player_seek_feedback_backward
+ },
+ messageArgs = listOf(seconds),
+ icon = if (direction == PlayerSeekDirection.Forward) {
+ GestureFeedbackIcon.SeekForward
+ } else {
+ GestureFeedbackIcon.SeekBackward
+ },
+ ),
+ )
+}
+
+internal fun PlayerScreenRuntime.showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) {
+ val deltaMs = previewPositionMs - baselinePositionMs
+ val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward
+ liveGestureFeedback = GestureFeedbackState(
+ message = formatPlaybackTime(previewPositionMs),
+ icon = if (direction == PlayerSeekDirection.Forward) {
+ GestureFeedbackIcon.SeekForward
+ } else {
+ GestureFeedbackIcon.SeekBackward
+ },
+ secondaryMessageRes = if (deltaMs >= 0L) {
+ Res.string.compose_player_seek_delta_forward
+ } else {
+ Res.string.compose_player_seek_delta_backward
+ },
+ secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()),
+ secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
+ Color(0xFF6EE7A8)
+ } else {
+ Color(0xFFFF9A76)
+ },
+ )
+}
+
+internal fun PlayerScreenRuntime.showBrightnessFeedback(level: Float) {
+ val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
+ showGestureFeedback(
+ GestureFeedbackState(
+ messageRes = Res.string.compose_player_brightness_level,
+ messageArgs = listOf("$percentage%"),
+ icon = GestureFeedbackIcon.Brightness,
+ ),
+ )
+}
+
+internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) {
+ val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
+ showGestureFeedback(
+ GestureFeedbackState(
+ messageRes = if (level.isMuted) {
+ Res.string.compose_player_muted
+ } else {
+ Res.string.compose_player_volume_level
+ },
+ messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"),
+ icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
+ isDanger = level.isMuted,
+ ),
+ )
+}
+
+internal fun PlayerScreenRuntime.togglePlayback() {
+ if (playbackSnapshot.isPlaying) {
+ shouldPlay = false
+ playerController?.pause()
+ } else {
+ if (playbackSnapshot.isEnded) {
+ playerController?.seekTo(0L)
+ }
+ shouldPlay = true
+ playerController?.play()
+ }
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) {
+ playerController?.seekBy(offsetMs)
+ scheduleProgressSyncAfterSeek()
+ controlsVisible = true
+ when {
+ offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
+ offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
+ }
+}
+
+internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) {
+ val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
+ val currentSeekState = accumulatedSeekState
+ val nextState = if (currentSeekState?.direction == direction) {
+ currentSeekState.copy(amountMs = currentSeekState.amountMs + PlayerDoubleTapSeekStepMs)
+ } else {
+ PlayerAccumulatedSeekState(
+ direction = direction,
+ baselinePositionMs = currentPositionMs,
+ amountMs = PlayerDoubleTapSeekStepMs,
+ )
+ }
+ accumulatedSeekState = nextState
+
+ val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L }
+ val targetPositionMs = when (direction) {
+ PlayerSeekDirection.Backward -> {
+ (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L)
+ }
+ PlayerSeekDirection.Forward -> {
+ val unclamped = nextState.baselinePositionMs + nextState.amountMs
+ maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
+ }
+ }
+ playerController?.seekTo(targetPositionMs)
+ scheduleProgressSyncAfterSeek()
+ showSeekFeedback(direction, nextState.amountMs)
+
+ accumulatedSeekResetJob?.cancel()
+ accumulatedSeekResetJob = scope.launch {
+ delay(PlayerDoubleTapSeekResetDelayMs)
+ accumulatedSeekState = null
+ }
+}
+
+internal fun PlayerScreenRuntime.cycleResizeMode() {
+ val nextMode = resizeMode.next()
+ resizeMode = nextMode
+ lastSyncedSettingsResizeMode = nextMode
+ PlayerSettingsRepository.setResizeMode(nextMode)
+ showGestureMessage(
+ when (nextMode) {
+ PlayerResizeMode.Fit -> resizeModeFitLabel
+ PlayerResizeMode.Fill -> resizeModeFillLabel
+ PlayerResizeMode.Zoom -> resizeModeZoomLabel
+ },
+ )
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.cyclePlaybackSpeed() {
+ val speeds = listOf(1f, 1.25f, 1.5f, 2f)
+ val current = playbackSnapshot.playbackSpeed
+ val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first()
+ playerController?.setPlaybackSpeed(next)
+ showGestureMessage(formatPlaybackSpeedLabel(next))
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.activateHoldToSpeed() {
+ if (!playerSettingsUiState.holdToSpeedEnabled) return
+ val controller = playerController ?: return
+ if (speedBoostRestoreSpeed != null) return
+
+ val targetSpeed = playerSettingsUiState.holdToSpeedValue
+ val currentSpeed = playbackSnapshot.playbackSpeed
+ if (abs(currentSpeed - targetSpeed) < 0.01f) return
+
+ isHoldToSpeedGestureActive = true
+ speedBoostRestoreSpeed = currentSpeed
+ controller.setPlaybackSpeed(targetSpeed)
+ liveGestureFeedback = GestureFeedbackState(
+ message = formatPlaybackSpeedLabel(targetSpeed),
+ icon = GestureFeedbackIcon.Speed,
+ )
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+}
+
+internal fun PlayerScreenRuntime.deactivateHoldToSpeed() {
+ isHoldToSpeedGestureActive = false
+ val restoreSpeed = speedBoostRestoreSpeed ?: return
+ playerController?.setPlaybackSpeed(restoreSpeed)
+ speedBoostRestoreSpeed = null
+ liveGestureFeedback = null
+}
+
+@Composable
+internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfaceGestureCallbacks {
+ val onSurfaceTap = rememberUpdatedState { offset: Offset ->
+ if (playerControlsLocked) {
+ revealLockedOverlay()
+ return@rememberUpdatedState
+ }
+ val centerStart = layoutSize.width * PlayerLeftGestureBoundary
+ val centerEnd = layoutSize.width * PlayerRightGestureBoundary
+ if (controlsVisible && offset.x in centerStart..centerEnd) {
+ controlsVisible = false
+ } else {
+ controlsVisible = !controlsVisible
+ }
+ }
+ val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
+ if (playerControlsLocked) {
+ revealLockedOverlay()
+ return@rememberUpdatedState
+ }
+ when {
+ offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
+ handleDoubleTapSeek(PlayerSeekDirection.Backward)
+ }
+ offset.x > layoutSize.width * PlayerRightGestureBoundary -> {
+ handleDoubleTapSeek(PlayerSeekDirection.Forward)
+ }
+ else -> controlsVisible = !controlsVisible
+ }
+ }
+ return PlayerSurfaceGestureCallbacks(
+ onSurfaceTap = onSurfaceTap,
+ onSurfaceDoubleTap = onSurfaceDoubleTap,
+ activateHoldToSpeed = rememberUpdatedState(::activateHoldToSpeed),
+ deactivateHoldToSpeed = rememberUpdatedState(::deactivateHoldToSpeed),
+ showHorizontalSeekPreview = rememberUpdatedState(::showHorizontalSeekPreview),
+ showBrightnessFeedback = rememberUpdatedState(::showBrightnessFeedback),
+ showVolumeFeedback = rememberUpdatedState(::showVolumeFeedback),
+ clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback),
+ revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay),
+ isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive),
+ playerControlsLocked = rememberUpdatedState(playerControlsLocked),
+ currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)),
+ currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs),
+ commitHorizontalSeek = rememberUpdatedState { targetPositionMs: Long ->
+ playerController?.seekTo(targetPositionMs)
+ scheduleProgressSyncAfterSeek()
+ },
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt
new file mode 100644
index 00000000..22828532
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt
@@ -0,0 +1,208 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.features.tmdb.TmdbService
+import com.nuvio.app.features.trakt.TraktScrobbleRepository
+import com.nuvio.app.features.watchprogress.WatchProgressClock
+import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
+import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+internal val PlayerScreenRuntime.activePlaybackIdentity: String
+ get() = activeTorrentInfoHash
+ ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" }
+ ?: activeSourceUrl
+
+internal val PlayerScreenRuntime.playbackSession: WatchProgressPlaybackSession
+ get() = WatchProgressPlaybackSession(
+ contentType = contentType ?: parentMetaType,
+ parentMetaId = parentMetaId,
+ parentMetaType = parentMetaType,
+ videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId(
+ parentMetaId = parentMetaId,
+ seasonNumber = activeSeasonNumber,
+ episodeNumber = activeEpisodeNumber,
+ fallbackVideoId = activeVideoId,
+ ),
+ title = title,
+ logo = logo,
+ poster = poster,
+ background = background,
+ seasonNumber = activeSeasonNumber,
+ episodeNumber = activeEpisodeNumber,
+ episodeTitle = activeEpisodeTitle,
+ episodeThumbnail = activeEpisodeThumbnail,
+ providerName = activeProviderName,
+ providerAddonId = activeProviderAddonId,
+ lastStreamTitle = activeStreamTitle,
+ lastStreamSubtitle = activeStreamSubtitle,
+ pauseDescription = pauseDescription,
+ lastSourceUrl = activeSourceUrl,
+ )
+
+internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
+ val identity = activePlaybackIdentity
+ if (lastResetPlaybackIdentity != identity) {
+ lastResetPlaybackIdentity = identity
+ shouldPlay = true
+ initialLoadCompleted = false
+ speedBoostRestoreSpeed = null
+ isHoldToSpeedGestureActive = false
+ initialSeekApplied = activeInitialPositionMs <= 0L &&
+ (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f)
+ lastProgressPersistEpochMs = 0L
+ previousIsPlaying = false
+ pendingScrobbleStartAfterSeek = false
+ autoFetchedAddonSubtitlesForKey = null
+ trackPreferenceRestoreApplied = false
+ preferredAudioSelectionApplied = false
+ preferredSubtitleSelectionApplied = false
+ }
+
+ val videoIdentity = "$identity:$activeVideoId:$activeSeasonNumber:$activeEpisodeNumber"
+ if (lastResetVideoIdentity != videoIdentity) {
+ lastResetVideoIdentity = videoIdentity
+ hasRequestedScrobbleStartForCurrentItem = false
+ scrobbleStartRequestGeneration = 0L
+ pendingScrobbleStartAfterSeek = false
+ hasSentCompletionScrobbleForCurrentItem = false
+ currentTraktScrobbleItem = null
+ }
+}
+
+internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
+ snapshot: PlayerPlaybackSnapshot = playbackSnapshot,
+): Float {
+ val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f
+ return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f)
+ .coerceIn(0f, 100f)
+}
+
+internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
+ TraktScrobbleRepository.buildItem(
+ contentType = contentType ?: parentMetaType,
+ parentMetaId = parentMetaId,
+ videoId = activeVideoId,
+ title = title,
+ seasonNumber = activeSeasonNumber,
+ episodeNumber = activeEpisodeNumber,
+ episodeTitle = activeEpisodeTitle,
+ )
+
+internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
+ if (hasRequestedScrobbleStartForCurrentItem) return
+ hasRequestedScrobbleStartForCurrentItem = true
+ val requestGeneration = scrobbleStartRequestGeneration + 1L
+ scrobbleStartRequestGeneration = requestGeneration
+
+ scope.launch {
+ val item = currentTraktScrobbleItem()
+ if (item == null) {
+ hasRequestedScrobbleStartForCurrentItem = false
+ return@launch
+ }
+ if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
+ return@launch
+ }
+ currentTraktScrobbleItem = item
+ TraktScrobbleRepository.scrobbleStart(
+ item = item,
+ progressPercent = currentPlaybackProgressPercent(),
+ )
+ }
+}
+
+internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) {
+ val provided = progressPercent
+ if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
+
+ val percent = provided ?: currentPlaybackProgressPercent()
+ val itemSnapshot = currentTraktScrobbleItem
+ scope.launch(NonCancellable) {
+ val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
+ TraktScrobbleRepository.scrobbleStop(
+ item = item,
+ progressPercent = percent,
+ )
+ }
+ currentTraktScrobbleItem = null
+ hasRequestedScrobbleStartForCurrentItem = false
+ scrobbleStartRequestGeneration += 1L
+}
+
+internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() {
+ val progressPercent = currentPlaybackProgressPercent()
+ if (progressPercent >= 1f && progressPercent < 80f) {
+ emitTraktScrobbleStop(progressPercent)
+ return
+ }
+
+ if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) {
+ hasSentCompletionScrobbleForCurrentItem = true
+ emitTraktScrobbleStop(progressPercent)
+ }
+}
+
+internal fun PlayerScreenRuntime.tryShowParentalGuide() {
+ if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) {
+ playbackStartedForParentalGuide = true
+ controlsVisible = true
+ showParentalGuide = true
+ parentalGuideHasShown = true
+ }
+}
+
+internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? {
+ val candidates = listOf(parentMetaId, activeVideoId)
+ candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it }
+ val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null
+ return TmdbService.tmdbToImdb(
+ tmdbId = tmdbId,
+ mediaType = contentType ?: parentMetaType,
+ )
+}
+
+internal fun PlayerScreenRuntime.flushWatchProgress() {
+ emitStopScrobbleForCurrentProgress()
+ WatchProgressRepository.flushPlaybackProgress(
+ session = playbackSession,
+ snapshot = playbackSnapshot,
+ )
+}
+
+internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
+ val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
+ seekProgressSyncJob?.cancel()
+ seekProgressSyncJob = scope.launch {
+ delay(PlayerSeekProgressSyncDebounceMs)
+ WatchProgressRepository.upsertPlaybackProgress(
+ session = playbackSession,
+ snapshot = playbackSnapshot,
+ )
+
+ val progressPercent = currentPlaybackProgressPercent()
+ if (progressPercent >= 1f && progressPercent < 80f) {
+ emitTraktScrobbleStop(progressPercent)
+ val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
+ if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
+ pendingScrobbleStartAfterSeek = false
+ emitTraktScrobbleStart()
+ } else if (shouldRestartScrobbleNow) {
+ pendingScrobbleStartAfterSeek = true
+ }
+ }
+ }
+}
+
+internal fun PlayerScreenRuntime.persistPlaybackProgressTick() {
+ val now = WatchProgressClock.nowEpochMs()
+ if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) return
+ lastProgressPersistEpochMs = now
+ WatchProgressRepository.upsertPlaybackProgress(
+ session = playbackSession,
+ snapshot = playbackSnapshot,
+ syncRemote = false,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt
new file mode 100644
index 00000000..87bf6545
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt
@@ -0,0 +1,424 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.core.ui.NuvioToastController
+import com.nuvio.app.features.debrid.DirectDebridPlayableResult
+import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
+import com.nuvio.app.features.debrid.toastMessage
+import com.nuvio.app.features.details.MetaDetailsRepository
+import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.downloads.DownloadItem
+import com.nuvio.app.features.downloads.DownloadsRepository
+import com.nuvio.app.features.p2p.P2pSettingsRepository
+import com.nuvio.app.features.p2p.P2pStreamingEngine
+import com.nuvio.app.features.streams.StreamItem
+import com.nuvio.app.features.streams.StreamLinkCacheRepository
+import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
+import kotlinx.coroutines.launch
+
+internal fun PlayerScreenRuntime.resolveDebridForPlayer(
+ stream: StreamItem,
+ season: Int?,
+ episode: Int?,
+ onResolved: (StreamItem) -> Unit,
+ onStale: () -> Unit,
+): Boolean {
+ if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false
+ scope.launch {
+ val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
+ stream = stream,
+ season = season,
+ episode = episode,
+ )
+ when (resolved) {
+ is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
+ else -> {
+ resolved.toastMessage()?.let { NuvioToastController.show(it) }
+ if (resolved == DirectDebridPlayableResult.Stale) {
+ onStale()
+ }
+ }
+ }
+ }
+ return true
+}
+
+internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
+ "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
+
+internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean =
+ stream.needsLocalDebridResolve && stream.p2pInfoHash != null
+
+internal fun PlayerScreenRuntime.stopActiveP2pStream() {
+ if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) {
+ P2pStreamingEngine.stopStream()
+ }
+ activeTorrentInfoHash = null
+ activeTorrentFileIdx = null
+ activeTorrentFilename = null
+ activeTorrentMagnetUri = null
+ activeTorrentTrackers = emptyList()
+ p2pResolvedSourceUrl = null
+}
+
+internal fun PlayerScreenRuntime.saveP2pStreamForReuse(
+ stream: StreamItem,
+ videoId: String?,
+ season: Int?,
+ episode: Int?,
+) {
+ if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return
+ val infoHash = stream.p2pInfoHash ?: return
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = contentType ?: parentMetaType,
+ videoId = videoId,
+ parentMetaId = parentMetaId,
+ season = season,
+ episode = episode,
+ )
+ StreamLinkCacheRepository.save(
+ contentKey = cacheKey,
+ url = "",
+ streamName = stream.streamLabel,
+ addonName = stream.addonName,
+ addonId = stream.addonId,
+ requestHeaders = emptyMap(),
+ responseHeaders = emptyMap(),
+ filename = stream.p2pFilename,
+ videoSize = stream.behaviorHints.videoSize,
+ infoHash = infoHash,
+ fileIdx = stream.p2pFileIdx,
+ magnetUri = stream.torrentMagnetUri,
+ sources = stream.p2pSourceHints,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ )
+}
+
+internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
+ val infoHash = stream.p2pInfoHash ?: return
+ if (!P2pSettingsRepository.isVisible) return
+ if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
+ pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false)
+ return
+ }
+ val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
+ flushWatchProgress()
+ stopActiveP2pStream()
+ saveP2pStreamForReuse(
+ stream = stream,
+ videoId = activeVideoId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ )
+ activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
+ activeSourceAudioUrl = null
+ activeSourceHeaders = emptyMap()
+ activeSourceResponseHeaders = emptyMap()
+ activeTorrentInfoHash = infoHash
+ activeTorrentFileIdx = stream.p2pFileIdx
+ activeTorrentFilename = stream.p2pFilename
+ activeTorrentMagnetUri = stream.torrentMagnetUri
+ activeTorrentTrackers = stream.p2pTrackers
+ activeStreamTitle = stream.streamLabel
+ activeStreamSubtitle = stream.streamSubtitle
+ activeProviderName = stream.addonName
+ activeProviderAddonId = stream.addonId
+ currentStreamBingeGroup = stream.behaviorHints.bingeGroup
+ activeInitialPositionMs = currentPositionMs
+ activeInitialProgressFraction = null
+ showSourcesPanel = false
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
+ stream: StreamItem,
+ episode: MetaVideo,
+ isAutoPlay: Boolean = false,
+) {
+ val infoHash = stream.p2pInfoHash ?: return
+ if (!P2pSettingsRepository.isVisible) return
+ if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
+ pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay)
+ return
+ }
+ resetEpisodePanelAndNextEpisodeState()
+ flushWatchProgress()
+ stopActiveP2pStream()
+ val epVideoId = episode.id
+ val resume = resolveEpisodeResume(epVideoId, episode)
+ saveP2pStreamForReuse(
+ stream = stream,
+ videoId = epVideoId,
+ season = episode.season,
+ episode = episode.episode,
+ )
+ activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
+ activeSourceAudioUrl = null
+ activeSourceHeaders = emptyMap()
+ activeSourceResponseHeaders = emptyMap()
+ activeTorrentInfoHash = infoHash
+ activeTorrentFileIdx = stream.p2pFileIdx
+ activeTorrentFilename = stream.p2pFilename
+ activeTorrentMagnetUri = stream.torrentMagnetUri
+ activeTorrentTrackers = stream.p2pTrackers
+ applyEpisodeStreamMetadata(stream, episode, resume)
+}
+
+internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
+ if (
+ resolveDebridForPlayer(
+ stream = stream,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ onResolved = { switchToSource(it) },
+ onStale = {
+ val vid = activeVideoId
+ if (vid != null) {
+ PlayerStreamsRepository.loadSources(
+ type = contentType ?: parentMetaType,
+ videoId = vid,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ forceRefresh = true,
+ )
+ }
+ },
+ )
+ ) return
+ if (isP2pStream(stream)) {
+ switchToP2pSourceStream(stream)
+ return
+ }
+ val url = stream.playableDirectUrl ?: return
+ if (url == activeSourceUrl) return
+ val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
+ flushWatchProgress()
+ stopActiveP2pStream()
+ val currentVideoId = activeVideoId
+ if (playerSettingsUiState.streamReuseLastLinkEnabled && currentVideoId != null) {
+ saveDirectStreamForReuse(stream, url, currentVideoId, activeSeasonNumber, activeEpisodeNumber)
+ }
+ activeSourceUrl = url
+ activeSourceAudioUrl = null
+ activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
+ activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
+ activeStreamTitle = stream.streamLabel
+ activeStreamSubtitle = stream.streamSubtitle
+ activeProviderName = stream.addonName
+ activeProviderAddonId = stream.addonId
+ currentStreamBingeGroup = stream.behaviorHints.bingeGroup
+ activeInitialPositionMs = currentPositionMs
+ activeInitialProgressFraction = null
+ showSourcesPanel = false
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
+ if (
+ resolveDebridForPlayer(
+ stream = stream,
+ season = episode.season,
+ episode = episode.episode,
+ onResolved = { resolvedStream -> switchToEpisodeStream(resolvedStream, episode) },
+ onStale = {
+ PlayerStreamsRepository.loadEpisodeStreams(
+ type = contentType ?: parentMetaType,
+ videoId = episode.id,
+ season = episode.season,
+ episode = episode.episode,
+ forceRefresh = true,
+ )
+ },
+ )
+ ) return
+ if (isP2pStream(stream)) {
+ switchToP2pEpisodeStream(stream, episode)
+ return
+ }
+ val url = stream.playableDirectUrl ?: return
+ resetEpisodePanelAndNextEpisodeState()
+ flushWatchProgress()
+ stopActiveP2pStream()
+ val epVideoId = episode.id
+ val resume = resolveEpisodeResume(epVideoId, episode)
+ if (playerSettingsUiState.streamReuseLastLinkEnabled) {
+ saveDirectStreamForReuse(stream, url, epVideoId, episode.season, episode.episode)
+ }
+ activeSourceUrl = url
+ activeSourceAudioUrl = null
+ activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
+ activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
+ applyEpisodeStreamMetadata(stream, episode, resume)
+}
+
+internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
+ val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
+ resetEpisodePanelAndNextEpisodeState()
+ flushWatchProgress()
+ stopActiveP2pStream()
+
+ val fallbackVideoId = buildPlaybackVideoId(
+ parentMetaId = parentMetaId,
+ seasonNumber = episode.season,
+ episodeNumber = episode.episode,
+ fallbackVideoId = episode.id,
+ )
+ val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId
+ val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId)
+ ?.takeIf { !it.isCompleted }
+ val epResumeFraction = epEntry?.progressPercent
+ ?.takeIf { it > 0f }
+ ?.let { (it / 100f).coerceIn(0f, 1f) }
+ val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
+
+ activeSourceUrl = localFileUri
+ activeSourceAudioUrl = null
+ activeSourceHeaders = emptyMap()
+ activeSourceResponseHeaders = emptyMap()
+ activeStreamTitle = downloadItem.streamTitle.ifBlank {
+ episode.title.ifBlank { title }
+ }
+ activeStreamSubtitle = downloadItem.streamSubtitle
+ activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel }
+ activeProviderAddonId = downloadItem.providerAddonId
+ currentStreamBingeGroup = null
+ activeSeasonNumber = episode.season
+ activeEpisodeNumber = episode.episode
+ activeEpisodeTitle = episode.title
+ activeEpisodeThumbnail = episode.thumbnail
+ activeVideoId = resolvedVideoId
+ activeInitialPositionMs = epResumePositionMs
+ activeInitialProgressFraction = epResumeFraction
+ controlsVisible = true
+}
+
+internal fun PlayerScreenRuntime.playNextEpisode() {
+ scope.launchPlayerNextEpisodeAutoPlay(
+ previousJob = nextEpisodeAutoPlayJob,
+ nextEpisodeInfo = nextEpisodeInfo,
+ allEpisodes = playerMetaVideos,
+ parentMetaId = parentMetaId,
+ parentMetaType = parentMetaType,
+ contentType = contentType,
+ settings = playerSettingsUiState,
+ currentStreamBingeGroup = currentStreamBingeGroup,
+ onDownloadedEpisodeSelected = { item, episode -> switchToDownloadedEpisode(item, episode) },
+ onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) },
+ onManualSelectionRequired = { nextVideo ->
+ episodeStreamsPanelState = EpisodeStreamsPanelState(
+ showStreams = true,
+ selectedEpisode = nextVideo,
+ )
+ showEpisodesPanel = true
+ },
+ onSearchingChanged = { nextEpisodeAutoPlaySearching = it },
+ onSourceNameChanged = { nextEpisodeAutoPlaySourceName = it },
+ onCountdownChanged = { nextEpisodeAutoPlayCountdown = it },
+ onNextEpisodeCardVisibleChanged = { showNextEpisodeCard = it },
+ )?.let { job ->
+ nextEpisodeAutoPlayJob = job
+ }
+}
+
+internal fun PlayerScreenRuntime.openSourcesPanel() {
+ val vid = activeVideoId ?: return
+ PlayerStreamsRepository.loadSources(
+ type = contentType ?: parentMetaType,
+ videoId = vid,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ )
+ showSourcesPanel = true
+ showEpisodesPanel = false
+ controlsVisible = false
+}
+
+internal fun PlayerScreenRuntime.openEpisodesPanel() {
+ if (playerMetaVideos.isEmpty()) {
+ scope.launch {
+ playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
+ }
+ }
+ showEpisodesPanel = true
+ showSourcesPanel = false
+ controlsVisible = false
+}
+
+private data class EpisodeResume(val positionMs: Long, val fraction: Float?)
+
+private fun PlayerScreenRuntime.resetEpisodePanelAndNextEpisodeState() {
+ showNextEpisodeCard = false
+ showSourcesPanel = false
+ showEpisodesPanel = false
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ nextEpisodeAutoPlayJob?.cancel()
+ nextEpisodeAutoPlaySearching = false
+ nextEpisodeAutoPlaySourceName = null
+ nextEpisodeAutoPlayCountdown = null
+ PlayerStreamsRepository.clearEpisodeStreams()
+}
+
+private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: MetaVideo): EpisodeResume {
+ val epResumeVideoId = buildPlaybackVideoId(
+ parentMetaId = parentMetaId,
+ seasonNumber = episode.season,
+ episodeNumber = episode.episode,
+ fallbackVideoId = epVideoId,
+ )
+ val epEntry = WatchProgressRepository.progressForVideo(
+ epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
+ )?.takeIf { !it.isCompleted }
+ val epResumeFraction = epEntry?.progressPercent
+ ?.takeIf { it > 0f }
+ ?.let { (it / 100f).coerceIn(0f, 1f) }
+ val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
+ return EpisodeResume(positionMs = epResumePositionMs, fraction = epResumeFraction)
+}
+
+private fun PlayerScreenRuntime.applyEpisodeStreamMetadata(
+ stream: StreamItem,
+ episode: MetaVideo,
+ resume: EpisodeResume,
+) {
+ activeStreamTitle = stream.streamLabel
+ activeStreamSubtitle = stream.streamSubtitle
+ activeProviderName = stream.addonName
+ activeProviderAddonId = stream.addonId
+ currentStreamBingeGroup = stream.behaviorHints.bingeGroup
+ activeSeasonNumber = episode.season
+ activeEpisodeNumber = episode.episode
+ activeEpisodeTitle = episode.title
+ activeEpisodeThumbnail = episode.thumbnail
+ activeVideoId = episode.id
+ activeInitialPositionMs = resume.positionMs
+ activeInitialProgressFraction = resume.fraction
+ controlsVisible = true
+}
+
+private fun PlayerScreenRuntime.saveDirectStreamForReuse(
+ stream: StreamItem,
+ url: String,
+ videoId: String,
+ season: Int?,
+ episode: Int?,
+) {
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = contentType ?: parentMetaType,
+ videoId = videoId,
+ parentMetaId = parentMetaId,
+ season = season,
+ episode = episode,
+ )
+ StreamLinkCacheRepository.save(
+ contentKey = cacheKey,
+ url = url,
+ streamName = stream.streamLabel,
+ addonName = stream.addonName,
+ addonId = stream.addonId,
+ requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
+ responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
+ filename = stream.behaviorHints.filename,
+ videoSize = stream.behaviorHints.videoSize,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt
new file mode 100644
index 00000000..cd000586
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt
@@ -0,0 +1,192 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.features.addons.AddonsUiState
+import com.nuvio.app.features.details.MetaDetailsUiState
+import com.nuvio.app.features.details.MetaScreenSettingsUiState
+import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.p2p.P2pSettingsUiState
+import com.nuvio.app.features.p2p.P2pStreamingState
+import com.nuvio.app.features.player.skip.NextEpisodeInfo
+import com.nuvio.app.features.player.skip.SkipInterval
+import com.nuvio.app.features.streams.StreamsUiState
+import com.nuvio.app.features.trakt.TraktScrobbleItem
+import com.nuvio.app.features.watched.WatchedUiState
+import com.nuvio.app.features.watchprogress.WatchProgressUiState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+
+internal class PlayerScreenRuntime(
+ args: PlayerScreenArgs,
+) {
+ var args by mutableStateOf(args)
+
+ val title: String get() = args.title
+ val sourceUrl: String get() = args.sourceUrl
+ val sourceAudioUrl: String? get() = args.sourceAudioUrl
+ val sourceHeaders: Map get() = args.sourceHeaders
+ val sourceResponseHeaders: Map get() = args.sourceResponseHeaders
+ val providerName: String get() = args.providerName
+ val streamTitle: String get() = args.streamTitle
+ val streamSubtitle: String? get() = args.streamSubtitle
+ val initialBingeGroup: String? get() = args.initialBingeGroup
+ val pauseDescription: String? get() = args.pauseDescription
+ val logo: String? get() = args.logo
+ val poster: String? get() = args.poster
+ val background: String? get() = args.background
+ val seasonNumber: Int? get() = args.seasonNumber
+ val episodeNumber: Int? get() = args.episodeNumber
+ val episodeTitle: String? get() = args.episodeTitle
+ val episodeThumbnail: String? get() = args.episodeThumbnail
+ val contentType: String? get() = args.contentType
+ val videoId: String? get() = args.videoId
+ val parentMetaId: String get() = args.parentMetaId
+ val parentMetaType: String get() = args.parentMetaType
+ val providerAddonId: String? get() = args.providerAddonId
+ val torrentInfoHash: String? get() = args.torrentInfoHash
+ val torrentFileIdx: Int? get() = args.torrentFileIdx
+ val torrentFilename: String? get() = args.torrentFilename
+ val torrentMagnetUri: String? get() = args.torrentMagnetUri
+ val torrentTrackers: List get() = args.torrentTrackers
+ val initialPositionMs: Long get() = args.initialPositionMs
+ val initialProgressFraction: Float? get() = args.initialProgressFraction
+ val isSeries: Boolean get() = parentMetaType == "series"
+
+ lateinit var scope: CoroutineScope
+ lateinit var hapticFeedback: HapticFeedback
+
+ var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState()
+ var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState()
+ var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle
+ var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
+ var watchedUiState: WatchedUiState = WatchedUiState()
+ var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
+ var sourceStreamsState: StreamsUiState = StreamsUiState()
+ var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
+ var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
+ var addonsUiState: AddonsUiState = AddonsUiState()
+ var addonSubtitles: List = emptyList()
+ var isLoadingAddonSubtitles: Boolean = false
+
+ var horizontalSafePadding: Dp = 0.dp
+ var metrics: PlayerLayoutMetrics = PlayerLayoutMetrics.fromWidth(0.dp)
+ var sliderEdgePadding: Dp = 0.dp
+ var overlayBottomPadding: Dp = 0.dp
+ var sideGestureSystemEdgeExclusionPx: Float = 0f
+ var resizeModeFitLabel: String = ""
+ var resizeModeFillLabel: String = ""
+ var resizeModeZoomLabel: String = ""
+ var downloadedLabel: String = ""
+ var airsPrefix: String = ""
+ var tbaLabel: String = ""
+ var genericUnknownLabel: String = ""
+ var parentalGuideLabels: ParentalGuideLabels = ParentalGuideLabels("", "", "", "", "", "", "", "")
+
+ var gestureController: PlayerGestureController? = null
+
+ var controlsVisible by mutableStateOf(true)
+ var playerControlsLocked by mutableStateOf(false)
+ var activeSourceUrl by mutableStateOf(sourceUrl)
+ var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl)
+ var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
+ var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
+ var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
+ var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
+ var activeTorrentFilename by mutableStateOf(torrentFilename)
+ var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri)
+ var activeTorrentTrackers by mutableStateOf(torrentTrackers)
+ var p2pResolvedSourceUrl by mutableStateOf(null)
+ var activeStreamTitle by mutableStateOf(streamTitle)
+ var activeStreamSubtitle by mutableStateOf(streamSubtitle)
+ var activeProviderName by mutableStateOf(providerName)
+ var activeProviderAddonId by mutableStateOf(providerAddonId)
+ var currentStreamBingeGroup by mutableStateOf(initialBingeGroup)
+ var activeSeasonNumber by mutableStateOf(seasonNumber)
+ var activeEpisodeNumber by mutableStateOf(episodeNumber)
+ var activeEpisodeTitle by mutableStateOf(episodeTitle)
+ var activeEpisodeThumbnail by mutableStateOf(episodeThumbnail)
+ var activeVideoId by mutableStateOf(videoId)
+ var activeInitialPositionMs by mutableStateOf(initialPositionMs)
+ var activeInitialProgressFraction by mutableStateOf(initialProgressFraction)
+ var shouldPlay by mutableStateOf(true)
+ var resizeMode by mutableStateOf(playerSettingsUiState.resizeMode)
+ var layoutSize by mutableStateOf(IntSize.Zero)
+ var playbackSnapshot by mutableStateOf(PlayerPlaybackSnapshot())
+ var playerController by mutableStateOf(null)
+ var playerControllerSourceUrl by mutableStateOf(null)
+ var errorMessage by mutableStateOf(null)
+ var isScrubbingTimeline by mutableStateOf(false)
+ var scrubbingPositionMs by mutableStateOf(null)
+ var pausedOverlayVisible by mutableStateOf(false)
+ var gestureFeedback by mutableStateOf(null)
+ var liveGestureFeedback by mutableStateOf(null)
+ var renderedGestureFeedback by mutableStateOf(null)
+ var lockedOverlayVisible by mutableStateOf(false)
+ var gestureMessageJob by mutableStateOf(null)
+ var accumulatedSeekResetJob by mutableStateOf(null)
+ var seekProgressSyncJob by mutableStateOf(null)
+ var accumulatedSeekState by mutableStateOf(null)
+ var initialLoadCompleted by mutableStateOf(false)
+ var speedBoostRestoreSpeed by mutableStateOf(null)
+ var isHoldToSpeedGestureActive by mutableStateOf(false)
+ var initialSeekApplied by mutableStateOf(
+ initialPositionMs <= 0L && ((initialProgressFraction ?: 0f) <= 0f),
+ )
+ var lastProgressPersistEpochMs by mutableStateOf(0L)
+ var previousIsPlaying by mutableStateOf(false)
+ var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false)
+ var scrobbleStartRequestGeneration by mutableStateOf(0L)
+ var pendingScrobbleStartAfterSeek by mutableStateOf(false)
+ var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false)
+ var currentTraktScrobbleItem by mutableStateOf(null)
+
+ var showSourcesPanel by mutableStateOf(false)
+ var showEpisodesPanel by mutableStateOf(false)
+ var showSubmitIntroModal by mutableStateOf(false)
+ var submitIntroSegmentType by mutableStateOf("intro")
+ var submitIntroStartTimeStr by mutableStateOf("00:00")
+ var submitIntroEndTimeStr by mutableStateOf("00:00")
+ var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState())
+ var playerMetaVideos by mutableStateOf>(emptyList())
+ var skipIntervals by mutableStateOf>(emptyList())
+ var activeSkipInterval by mutableStateOf(null)
+ var skipIntervalDismissed by mutableStateOf(false)
+ var parentalWarnings by mutableStateOf>(emptyList())
+ var showParentalGuide by mutableStateOf(false)
+ var parentalGuideHasShown by mutableStateOf(false)
+ var playbackStartedForParentalGuide by mutableStateOf(false)
+ var nextEpisodeInfo by mutableStateOf(null)
+ var showNextEpisodeCard by mutableStateOf(false)
+ var nextEpisodeAutoPlaySearching by mutableStateOf(false)
+ var nextEpisodeAutoPlaySourceName by mutableStateOf(null)
+ var nextEpisodeAutoPlayCountdown by mutableStateOf(null)
+ var nextEpisodeAutoPlayJob by mutableStateOf(null)
+ var pendingP2pSwitch by mutableStateOf(null)
+
+ var showAudioModal by mutableStateOf(false)
+ var showSubtitleModal by mutableStateOf(false)
+ var showVideoSettingsModal by mutableStateOf(false)
+ var audioTracks by mutableStateOf>(emptyList())
+ var subtitleTracks by mutableStateOf>(emptyList())
+ var selectedAudioIndex by mutableStateOf(-1)
+ var selectedSubtitleIndex by mutableStateOf(-1)
+ var selectedAddonSubtitleId by mutableStateOf(null)
+ var useCustomSubtitles by mutableStateOf(false)
+ var preferredAudioSelectionApplied by mutableStateOf(false)
+ var preferredSubtitleSelectionApplied by mutableStateOf(false)
+ var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn)
+ var autoFetchedAddonSubtitlesForKey by mutableStateOf(null)
+ var trackPreferenceRestoreApplied by mutableStateOf(false)
+ var subtitleDelayMs by mutableStateOf(0)
+ var subtitleAutoSyncState by mutableStateOf(SubtitleAutoSyncUiState())
+
+ var lastSyncedSettingsResizeMode: PlayerResizeMode? = null
+ var lastResetPlaybackIdentity: String? = null
+ var lastResetVideoIdentity: String? = null
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt
new file mode 100644
index 00000000..fa9c8afc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt
@@ -0,0 +1,63 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.features.addons.httpGetTextWithHeaders
+import kotlinx.coroutines.launch
+
+internal fun PlayerScreenRuntime.fetchAddonSubtitlesForActiveItem() {
+ val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
+ val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
+ SubtitleRepository.fetchAddonSubtitles(type, videoId)
+}
+
+internal fun PlayerScreenRuntime.setSubtitleDelay(delayMs: Int) {
+ val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
+ subtitleDelayMs = clamped
+ PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped)
+ playerController?.setSubtitleDelayMs(clamped)
+}
+
+internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false) {
+ val subtitle = selectedAddonSubtitle ?: return
+ if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null)
+ scope.launch {
+ val result = runCatching {
+ val body = httpGetTextWithHeaders(
+ url = subtitle.url,
+ headers = sanitizePlaybackHeaders(activeSourceHeaders),
+ )
+ PlayerSubtitleCueParser.parse(body, subtitle.url)
+ }
+ result.fold(
+ onSuccess = { cues ->
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ cues = cues,
+ isLoading = false,
+ errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
+ )
+ },
+ onFailure = { error ->
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ isLoading = false,
+ errorMessage = error.message ?: "Unable to load subtitle lines",
+ )
+ },
+ )
+ }
+}
+
+internal fun PlayerScreenRuntime.captureSubtitleAutoSyncTime() {
+ subtitleAutoSyncState = subtitleAutoSyncState.copy(
+ capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L),
+ errorMessage = null,
+ )
+ loadSubtitleAutoSyncCues()
+}
+
+internal fun PlayerScreenRuntime.applySubtitleAutoSyncCue(cue: SubtitleSyncCue) {
+ val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return
+ val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS)
+ .toInt()
+ .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
+ setSubtitleDelay(newDelayMs)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt
new file mode 100644
index 00000000..118baba9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt
@@ -0,0 +1,217 @@
+package com.nuvio.app.features.player
+
+internal val PlayerScreenRuntime.subtitleStyle: SubtitleStyleState
+ get() = playerSettingsUiState.subtitleStyle
+
+internal val PlayerScreenRuntime.activeAddonSubtitleType: String
+ get() = contentType ?: parentMetaType
+
+internal val PlayerScreenRuntime.addonSubtitleFetchKey: String?
+ get() = buildAddonSubtitleFetchKey(
+ addons = addonsUiState.addons,
+ type = activeAddonSubtitleType,
+ videoId = activeVideoId,
+ )
+
+internal val PlayerScreenRuntime.visibleAddonSubtitles: List
+ get() = filterAddonSubtitlesForSettings(
+ subtitles = addonSubtitles,
+ settings = playerSettingsUiState,
+ selectedAddonSubtitleId = selectedAddonSubtitleId,
+ )
+
+internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle?
+ get() = addonSubtitles.firstOrNull { subtitle ->
+ subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
+ }
+
+internal fun PlayerScreenRuntime.updateTrackPreference(
+ update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference,
+) {
+ if (parentMetaId.isBlank()) return
+ val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
+ PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
+}
+
+internal fun PlayerScreenRuntime.persistAudioPreference(track: AudioTrack?) {
+ updateTrackPreference { current ->
+ current.copy(
+ audioLanguage = track?.language,
+ audioName = track?.label,
+ audioTrackId = track?.id,
+ )
+ }
+}
+
+internal fun PlayerScreenRuntime.persistInternalSubtitlePreference(track: SubtitleTrack?) {
+ updateTrackPreference { current ->
+ current.copy(
+ subtitleType = if (track == null) {
+ PersistedSubtitleSelectionType.DISABLED
+ } else {
+ PersistedSubtitleSelectionType.INTERNAL
+ },
+ subtitleLanguage = track?.language,
+ subtitleName = track?.label,
+ subtitleTrackId = track?.id,
+ addonSubtitleId = null,
+ addonSubtitleUrl = null,
+ addonSubtitleAddonName = null,
+ )
+ }
+}
+
+internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
+ updateTrackPreference { current ->
+ current.copy(
+ subtitleType = PersistedSubtitleSelectionType.ADDON,
+ subtitleLanguage = subtitle.language,
+ subtitleName = subtitle.display,
+ subtitleTrackId = null,
+ addonSubtitleId = subtitle.id,
+ addonSubtitleUrl = subtitle.url,
+ addonSubtitleAddonName = subtitle.addonName,
+ )
+ }
+}
+
+internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() {
+ if (trackPreferenceRestoreApplied) return
+ val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
+ if (preference == null) {
+ trackPreferenceRestoreApplied = true
+ return
+ }
+
+ if (
+ audioTracks.isNotEmpty() &&
+ (!preference.audioTrackId.isNullOrBlank() ||
+ !preference.audioLanguage.isNullOrBlank() ||
+ !preference.audioName.isNullOrBlank())
+ ) {
+ val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
+ if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
+ playerController?.selectAudioTrack(restoredAudioIndex)
+ selectedAudioIndex = restoredAudioIndex
+ }
+ preferredAudioSelectionApplied = true
+ }
+
+ when (preference.subtitleType) {
+ PersistedSubtitleSelectionType.DISABLED -> {
+ playerController?.selectSubtitleTrack(-1)
+ selectedSubtitleIndex = -1
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ preferredSubtitleSelectionApplied = true
+ }
+ PersistedSubtitleSelectionType.INTERNAL -> {
+ if (subtitleTracks.isNotEmpty()) {
+ val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
+ if (restoredSubtitleIndex >= 0) {
+ if (useCustomSubtitles) {
+ playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
+ } else {
+ playerController?.selectSubtitleTrack(restoredSubtitleIndex)
+ }
+ selectedSubtitleIndex = restoredSubtitleIndex
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ preferredSubtitleSelectionApplied = true
+ }
+ }
+ }
+ PersistedSubtitleSelectionType.ADDON -> {
+ val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
+ if (url != null) {
+ selectedAddonSubtitleId = preference.addonSubtitleId ?: url
+ selectedSubtitleIndex = -1
+ useCustomSubtitles = true
+ playerController?.setSubtitleUri(url)
+ preferredSubtitleSelectionApplied = true
+ }
+ }
+ }
+
+ trackPreferenceRestoreApplied = true
+}
+
+internal fun PlayerScreenRuntime.refreshTracks() {
+ val ctrl = playerController ?: return
+ audioTracks = ctrl.getAudioTracks()
+ subtitleTracks = ctrl.getSubtitleTracks()
+ val selectedAudio = audioTracks.firstOrNull { it.isSelected }
+ if (selectedAudio != null) selectedAudioIndex = selectedAudio.index
+ val selectedSub = subtitleTracks.firstOrNull { it.isSelected }
+ if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index
+
+ restorePersistedTrackPreferenceIfNeeded()
+
+ if (!preferredAudioSelectionApplied) {
+ val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
+ preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
+ secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
+ deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
+ )
+ if (preferredAudioTargets.isEmpty()) {
+ preferredAudioSelectionApplied = true
+ } else if (audioTracks.isNotEmpty()) {
+ val preferredAudioIndex = findPreferredTrackIndex(
+ tracks = audioTracks,
+ targets = preferredAudioTargets,
+ language = { track -> track.language },
+ )
+ if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) {
+ playerController?.selectAudioTrack(preferredAudioIndex)
+ selectedAudioIndex = preferredAudioIndex
+ }
+ preferredAudioSelectionApplied = true
+ }
+ }
+
+ if (!preferredSubtitleSelectionApplied) {
+ val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
+ preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
+ SubtitleLanguageOption.FORCED
+ } else {
+ playerSettingsUiState.preferredSubtitleLanguage
+ },
+ secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
+ deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
+ )
+
+ if (preferredSubtitleTargets.isEmpty()) {
+ if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
+ playerController?.selectSubtitleTrack(-1)
+ }
+ selectedSubtitleIndex = -1
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ preferredSubtitleSelectionApplied = true
+ } else if (subtitleTracks.isNotEmpty()) {
+ val preferredSubtitleIndex = findPreferredSubtitleTrackIndex(
+ tracks = subtitleTracks,
+ targets = preferredSubtitleTargets,
+ )
+ if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) {
+ playerController?.selectSubtitleTrack(preferredSubtitleIndex)
+ selectedSubtitleIndex = preferredSubtitleIndex
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ } else if (
+ preferredSubtitleIndex < 0 &&
+ (subtitleStyle.useForcedSubtitles ||
+ normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) ==
+ SubtitleLanguageOption.FORCED)
+ ) {
+ if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
+ playerController?.selectSubtitleTrack(-1)
+ }
+ selectedSubtitleIndex = -1
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ }
+ preferredSubtitleSelectionApplied = true
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt
new file mode 100644
index 00000000..6319676f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt
@@ -0,0 +1,523 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import com.nuvio.app.features.p2p.P2pStreamingState
+import com.nuvio.app.features.p2p.formatP2pMegabytes
+import com.nuvio.app.features.p2p.formatP2pSpeed
+import com.nuvio.app.isIos
+import kotlinx.coroutines.launch
+import nuvio.composeapp.generated.resources.*
+
+@Composable
+internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
+ val runtime = this
+ val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
+ val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
+ val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
+ val isP2pPlaybackActive = activeTorrentInfoHash != null
+ val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming
+ val p2pPeerInfo = p2pStats?.let { stats ->
+ org.jetbrains.compose.resources.stringResource(
+ nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info,
+ stats.seeds,
+ stats.peers,
+ )
+ }
+ val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) }
+ val p2pInitialLoadingMessage = when {
+ !isP2pPlaybackActive || initialLoadCompleted -> null
+ p2pStreamingState is P2pStreamingState.Connecting -> {
+ org.jetbrains.compose.resources.stringResource(
+ nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers,
+ )
+ }
+ p2pStats != null -> {
+ if (p2pSettingsUiState.hideTorrentStats) {
+ null
+ } else {
+ org.jetbrains.compose.resources.stringResource(
+ nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status,
+ formatP2pMegabytes(p2pStats.preloadedBytes),
+ p2pPeerInfo.orEmpty(),
+ p2pDownloadSpeed.orEmpty(),
+ )
+ }
+ }
+ else -> org.jetbrains.compose.resources.stringResource(
+ nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine,
+ )
+ }
+ val p2pInitialLoadingProgress = when {
+ !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null
+ else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f)
+ }
+ val showP2pRebufferStats = isP2pPlaybackActive &&
+ initialLoadCompleted &&
+ playbackSnapshot.isLoading &&
+ p2pStats != null &&
+ !p2pSettingsUiState.hideTorrentStats
+ val p2pRebufferMessage = when {
+ !showP2pRebufferStats -> null
+ else -> {
+ val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L)
+ .coerceAtLeast(0L)
+ "${bufferedSeconds}s buffered · ${p2pPeerInfo.orEmpty()} · ${p2pDownloadSpeed.orEmpty()}"
+ }
+ }
+ val p2pRebufferProgress = when {
+ !showP2pRebufferStats -> null
+ else -> {
+ val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f)
+ .coerceAtLeast(0f)
+ (bufferedSeconds / 10f).coerceIn(0f, 1f)
+ }
+ }
+ val gestureCallbacks = rememberSurfaceGestureCallbacks()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged { layoutSize = it }
+ .playerSurfaceTapGestures(
+ layoutSize = layoutSize,
+ playerControlsLockedState = gestureCallbacks.playerControlsLocked,
+ onSurfaceTap = gestureCallbacks.onSurfaceTap,
+ onSurfaceDoubleTap = gestureCallbacks.onSurfaceDoubleTap,
+ activateHoldToSpeedState = gestureCallbacks.activateHoldToSpeed,
+ deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed,
+ revealLockedOverlayState = gestureCallbacks.revealLockedOverlay,
+ )
+ .playerSurfaceDragGestures(
+ gestureController = gestureController,
+ layoutSize = layoutSize,
+ sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx,
+ playerControlsLockedState = gestureCallbacks.playerControlsLocked,
+ isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive,
+ currentPositionMsState = gestureCallbacks.currentPositionMs,
+ currentDurationMsState = gestureCallbacks.currentDurationMs,
+ deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed,
+ showHorizontalSeekPreviewState = gestureCallbacks.showHorizontalSeekPreview,
+ showBrightnessFeedbackState = gestureCallbacks.showBrightnessFeedback,
+ showVolumeFeedbackState = gestureCallbacks.showVolumeFeedback,
+ clearLiveGestureFeedbackState = gestureCallbacks.clearLiveGestureFeedback,
+ revealLockedOverlayState = gestureCallbacks.revealLockedOverlay,
+ commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek,
+ ),
+ ) {
+ val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
+ if (playerSurfaceSourceUrl != null) {
+ PlatformPlayerSurface(
+ sourceUrl = playerSurfaceSourceUrl,
+ sourceAudioUrl = activeSourceAudioUrl,
+ sourceHeaders = activeSourceHeaders,
+ sourceResponseHeaders = activeSourceResponseHeaders,
+ modifier = Modifier.fillMaxSize(),
+ playWhenReady = shouldPlay,
+ resizeMode = resizeMode,
+ onControllerReady = { controller ->
+ playerController = controller
+ playerControllerSourceUrl = activeSourceUrl
+ },
+ onSnapshot = { snapshot ->
+ playbackSnapshot = snapshot
+ if (!snapshot.isLoading) initialLoadCompleted = true
+ if (snapshot.isEnded) {
+ shouldPlay = false
+ controlsVisible = !playerControlsLocked
+ }
+ },
+ onError = { message ->
+ errorMessage = message
+ if (message != null) {
+ controlsVisible = !playerControlsLocked
+ removeFailedStreamFromCache()
+ }
+ },
+ )
+ }
+
+ AnimatedVisibility(
+ visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked,
+ enter = fadeIn(animationSpec = tween(durationMillis = 220)),
+ exit = fadeOut(animationSpec = tween(durationMillis = 180)),
+ ) {
+ PauseMetadataOverlay(
+ title = title,
+ logo = logo,
+ isEpisode = isEpisode,
+ seasonNumber = activeSeasonNumber,
+ episodeNumber = activeEpisodeNumber,
+ episodeTitle = activeEpisodeTitle,
+ pauseDescription = pauseDescription ?: activeStreamSubtitle,
+ providerName = activeProviderName,
+ metrics = metrics,
+ horizontalSafePadding = horizontalSafePadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ RenderPlayerControls(displayedPositionMs = displayedPositionMs, isEpisode = isEpisode)
+ RenderPlaybackOverlays(
+ runtime = runtime,
+ displayedPositionMs = displayedPositionMs,
+ currentGestureFeedback = currentGestureFeedback,
+ p2pInitialLoadingMessage = p2pInitialLoadingMessage,
+ p2pInitialLoadingProgress = p2pInitialLoadingProgress,
+ showP2pRebufferStats = showP2pRebufferStats,
+ p2pRebufferMessage = p2pRebufferMessage,
+ p2pRebufferProgress = p2pRebufferProgress,
+ )
+ RenderPlayerModals(displayedPositionMs = displayedPositionMs)
+ }
+}
+
+@Composable
+private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) {
+ AnimatedVisibility(
+ visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ PlayerControlsShell(
+ title = title,
+ streamTitle = activeStreamTitle,
+ providerName = activeProviderName,
+ seasonNumber = activeSeasonNumber,
+ episodeNumber = activeEpisodeNumber,
+ episodeTitle = activeEpisodeTitle,
+ playbackSnapshot = playbackSnapshot,
+ displayedPositionMs = displayedPositionMs,
+ metrics = metrics,
+ resizeMode = resizeMode,
+ isLocked = playerControlsLocked,
+ showPlaybackControls = controlsVisible,
+ onLockToggle = {
+ if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls()
+ },
+ onBack = {
+ flushWatchProgress()
+ args.onBack()
+ },
+ onTogglePlayback = { togglePlayback() },
+ onSeekBack = { seekBy(-10_000L) },
+ onSeekForward = { seekBy(10_000L) },
+ onResizeModeClick = { cycleResizeMode() },
+ onSpeedClick = { cyclePlaybackSpeed() },
+ onSubtitleClick = {
+ refreshTracks()
+ showSubtitleModal = true
+ },
+ onAudioClick = {
+ refreshTracks()
+ showAudioModal = true
+ },
+ onVideoSettingsClick = if (isIos) {
+ {
+ showVideoSettingsModal = true
+ controlsVisible = true
+ }
+ } else {
+ null
+ },
+ onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
+ onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
+ onOpenInExternalPlayer = args.onOpenInExternalPlayer?.let { openExternal ->
+ {
+ val loadedSubtitles = addonSubtitles
+ .takeIf { it.isNotEmpty() }
+ ?.map { sub ->
+ SubtitleInput(
+ url = sub.url,
+ name = buildString {
+ if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ")
+ append(sub.display)
+ },
+ lang = sub.language,
+ )
+ }
+ openExternal(
+ ExternalPlayerPlaybackRequest(
+ sourceUrl = activeSourceUrl,
+ title = title,
+ streamTitle = activeStreamTitle,
+ sourceHeaders = activeSourceHeaders,
+ resumePositionMs = playbackSnapshot.positionMs,
+ subtitles = loadedSubtitles,
+ ),
+ )
+ }
+ },
+ onSubmitIntroClick = if (
+ isSeries &&
+ playerSettingsUiState.introSubmitEnabled &&
+ playerSettingsUiState.introDbApiKey.isNotBlank()
+ ) {
+ { showSubmitIntroModal = true }
+ } else {
+ null
+ },
+ parentalWarnings = parentalWarnings,
+ showParentalGuide = showParentalGuide,
+ onParentalGuideAnimationComplete = { showParentalGuide = false },
+ onScrubChange = { positionMs ->
+ isScrubbingTimeline = true
+ scrubbingPositionMs = positionMs
+ },
+ onScrubFinished = { positionMs ->
+ isScrubbingTimeline = false
+ scrubbingPositionMs = null
+ playerController?.seekTo(positionMs)
+ scheduleProgressSyncAfterSeek()
+ },
+ horizontalSafePadding = horizontalSafePadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}
+
+@Composable
+private fun BoxScope.RenderPlaybackOverlays(
+ runtime: PlayerScreenRuntime,
+ displayedPositionMs: Long,
+ currentGestureFeedback: GestureFeedbackState?,
+ p2pInitialLoadingMessage: String?,
+ p2pInitialLoadingProgress: Float?,
+ showP2pRebufferStats: Boolean,
+ p2pRebufferMessage: String?,
+ p2pRebufferProgress: Float?,
+) {
+ runtime.run {
+ PlayerPlaybackOverlays(
+ playerControlsLocked = playerControlsLocked,
+ lockedOverlayVisible = lockedOverlayVisible,
+ playbackSnapshot = playbackSnapshot,
+ displayedPositionMs = displayedPositionMs,
+ metrics = metrics,
+ horizontalSafePadding = horizontalSafePadding,
+ onUnlock = { unlockPlayerControls() },
+ showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
+ backdropArtwork = background ?: poster,
+ logo = logo,
+ title = title,
+ onBackWithProgress = {
+ flushWatchProgress()
+ args.onBack()
+ },
+ p2pInitialLoadingMessage = p2pInitialLoadingMessage,
+ p2pInitialLoadingProgress = p2pInitialLoadingProgress,
+ showP2pRebufferStats = showP2pRebufferStats,
+ p2pRebufferMessage = p2pRebufferMessage,
+ p2pRebufferProgress = p2pRebufferProgress,
+ currentGestureFeedback = currentGestureFeedback,
+ renderedGestureFeedback = renderedGestureFeedback,
+ initialLoadCompleted = initialLoadCompleted,
+ pausedOverlayVisible = pausedOverlayVisible,
+ activeSkipInterval = activeSkipInterval,
+ skipIntervalDismissed = skipIntervalDismissed,
+ controlsVisible = controlsVisible,
+ onSkipInterval = { interval ->
+ playerController?.seekTo((interval.endTime * 1000).toLong())
+ scheduleProgressSyncAfterSeek()
+ skipIntervalDismissed = true
+ },
+ onDismissSkipInterval = { skipIntervalDismissed = true },
+ sliderEdgePadding = sliderEdgePadding,
+ overlayBottomPadding = overlayBottomPadding,
+ isSeries = isSeries,
+ nextEpisodeInfo = nextEpisodeInfo,
+ showNextEpisodeCard = showNextEpisodeCard,
+ nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching,
+ nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName,
+ nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown,
+ onPlayNextEpisode = {
+ nextEpisodeAutoPlayJob?.cancel()
+ playNextEpisode()
+ },
+ onDismissNextEpisode = {
+ nextEpisodeAutoPlayJob?.cancel()
+ showNextEpisodeCard = false
+ nextEpisodeAutoPlaySearching = false
+ nextEpisodeAutoPlaySourceName = null
+ nextEpisodeAutoPlayCountdown = null
+ },
+ errorMessage = errorMessage,
+ onDismissError = {
+ flushWatchProgress()
+ args.onBack()
+ },
+ )
+ }
+}
+
+@Composable
+private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
+ PlayerScreenModalHosts(
+ pendingP2pSwitch = pendingP2pSwitch,
+ onPendingP2pSwitchChanged = { pendingP2pSwitch = it },
+ onP2pEpisodeStreamSelected = { stream, episode, isAutoPlay ->
+ switchToP2pEpisodeStream(stream, episode, isAutoPlay)
+ },
+ onP2pSourceStreamSelected = { stream -> switchToP2pSourceStream(stream) },
+ onNextEpisodeAutoPlaySearchingChanged = { nextEpisodeAutoPlaySearching = it },
+ onNextEpisodeAutoPlayCountdownChanged = { nextEpisodeAutoPlayCountdown = it },
+ onNextEpisodeAutoPlaySourceNameChanged = { nextEpisodeAutoPlaySourceName = it },
+ showAudioModal = showAudioModal,
+ audioTracks = audioTracks,
+ selectedAudioIndex = selectedAudioIndex,
+ onAudioTrackSelected = { index ->
+ selectedAudioIndex = index
+ persistAudioPreference(audioTracks.firstOrNull { it.index == index })
+ playerController?.selectAudioTrack(index)
+ scope.launch {
+ kotlinx.coroutines.delay(200)
+ showAudioModal = false
+ }
+ },
+ onAudioModalDismissed = { showAudioModal = false },
+ showSubtitleModal = showSubtitleModal,
+ activeSubtitleTab = activeSubtitleTab,
+ subtitleTracks = subtitleTracks,
+ selectedSubtitleIndex = selectedSubtitleIndex,
+ addonSubtitles = visibleAddonSubtitles,
+ selectedAddonSubtitleId = selectedAddonSubtitleId,
+ isLoadingAddonSubtitles = isLoadingAddonSubtitles,
+ subtitleStyle = subtitleStyle,
+ subtitleDelayMs = subtitleDelayMs,
+ selectedAddonSubtitle = selectedAddonSubtitle,
+ subtitleAutoSyncState = subtitleAutoSyncState,
+ onSubtitleTabSelected = { activeSubtitleTab = it },
+ onBuiltInSubtitleTrackSelected = { index ->
+ val wasCustom = useCustomSubtitles
+ selectedSubtitleIndex = index
+ selectedAddonSubtitleId = null
+ useCustomSubtitles = false
+ persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index })
+ if (wasCustom) {
+ playerController?.clearExternalSubtitleAndSelect(index)
+ } else {
+ playerController?.selectSubtitleTrack(index)
+ }
+ },
+ onAddonSubtitleSelected = { addon ->
+ selectedAddonSubtitleId = addon.id
+ selectedSubtitleIndex = -1
+ useCustomSubtitles = true
+ persistAddonSubtitlePreference(addon)
+ playerController?.setSubtitleUri(addon.url)
+ },
+ onFetchAddonSubtitles = { fetchAddonSubtitlesForActiveItem() },
+ onSubtitleStyleChanged = PlayerSettingsRepository::setSubtitleStyle,
+ onSubtitleDelayChanged = { delayMs -> setSubtitleDelay(delayMs) },
+ onSubtitleDelayReset = { setSubtitleDelay(0) },
+ onAutoSyncCapture = { captureSubtitleAutoSyncTime() },
+ onAutoSyncCueSelected = { cue -> applySubtitleAutoSyncCue(cue) },
+ onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) },
+ onSubtitleModalDismissed = { showSubtitleModal = false },
+ showVideoSettingsModal = showVideoSettingsModal,
+ playerSettings = playerSettingsUiState,
+ onVideoSettingsChanged = {
+ playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value)
+ },
+ onVideoSettingsModalDismissed = { showVideoSettingsModal = false },
+ showSourcesPanel = showSourcesPanel,
+ sourceStreamsState = sourceStreamsState,
+ activeSourceUrl = activeSourceUrl,
+ activeStreamTitle = activeStreamTitle,
+ onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter,
+ onSourceStreamSelected = { stream -> switchToSource(stream) },
+ onReloadSources = {
+ val vid = activeVideoId
+ if (vid != null) {
+ PlayerStreamsRepository.loadSources(
+ type = contentType ?: parentMetaType,
+ videoId = vid,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
+ forceRefresh = true,
+ )
+ }
+ },
+ onSourcesPanelDismissed = {
+ showSourcesPanel = false
+ controlsVisible = true
+ },
+ isSeries = isSeries,
+ showEpisodesPanel = showEpisodesPanel,
+ allEpisodes = playerMetaVideos,
+ parentMetaType = parentMetaType,
+ parentMetaId = parentMetaId,
+ activeSeasonNumber = activeSeasonNumber,
+ activeEpisodeNumber = activeEpisodeNumber,
+ watchProgressByVideoId = watchProgressUiState.byVideoId,
+ watchedKeys = watchedUiState.watchedKeys,
+ blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
+ episodeStreamsPanelState = episodeStreamsPanelState,
+ episodeStreamsRepoState = episodeStreamsRepoState,
+ onEpisodeSelectedForDownload = { episode ->
+ selectDownloadedEpisodeForPlayback(
+ parentMetaId = parentMetaId,
+ episode = episode,
+ onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) },
+ )
+ },
+ onEpisodeStreamsRequested = { episode ->
+ PlayerStreamsRepository.loadEpisodeStreams(
+ type = contentType ?: parentMetaType,
+ videoId = episode.id,
+ season = episode.season,
+ episode = episode.episode,
+ )
+ episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode)
+ },
+ onEpisodeStreamFilterSelected = PlayerStreamsRepository::selectEpisodeStreamsFilter,
+ onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) },
+ onBackToEpisodes = {
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ PlayerStreamsRepository.clearEpisodeStreams()
+ },
+ onReloadEpisodeStreams = {
+ val episode = episodeStreamsPanelState.selectedEpisode
+ if (episode != null) {
+ PlayerStreamsRepository.loadEpisodeStreams(
+ type = contentType ?: parentMetaType,
+ videoId = episode.id,
+ season = episode.season,
+ episode = episode.episode,
+ forceRefresh = true,
+ )
+ }
+ },
+ onEpisodesPanelDismissed = {
+ showEpisodesPanel = false
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ PlayerStreamsRepository.clearEpisodeStreams()
+ controlsVisible = true
+ },
+ showSubmitIntroModal = showSubmitIntroModal,
+ activeVideoId = activeVideoId,
+ metaUiState = metaUiState,
+ displayedPositionMs = displayedPositionMs,
+ submitIntroSegmentType = submitIntroSegmentType,
+ onSubmitIntroSegmentTypeChanged = { submitIntroSegmentType = it },
+ submitIntroStartTimeStr = submitIntroStartTimeStr,
+ onSubmitIntroStartTimeChanged = { submitIntroStartTimeStr = it },
+ submitIntroEndTimeStr = submitIntroEndTimeStr,
+ onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it },
+ onSubmitIntroDismissed = { showSubmitIntroModal = false },
+ onSubmitIntroSuccess = {
+ submitIntroStartTimeStr = "00:00"
+ submitIntroEndTimeStr = "00:00"
+ submitIntroSegmentType = "intro"
+ showSubmitIntroModal = false
+ },
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt
new file mode 100644
index 00000000..83c7944a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt
@@ -0,0 +1,59 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.streams.StreamItem
+
+internal const val PlaybackProgressPersistIntervalMs = 60_000L
+internal const val PlayerDoubleTapSeekStepMs = 10_000L
+internal const val PlayerDoubleTapSeekResetDelayMs = 800L
+internal const val PlayerLockedOverlayDurationMs = 2_000L
+internal const val PlayerLeftGestureBoundary = 0.4f
+internal const val PlayerRightGestureBoundary = 0.6f
+internal const val PlayerVerticalGestureSensitivity = 0.65f
+internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f
+internal const val PlayerVerticalGestureMinHeightFraction = 0.06f
+internal const val PlayerVerticalGestureDominanceRatio = 1.2f
+internal const val PlayerSeekProgressSyncDebounceMs = 700L
+internal const val P2pInitialPreloadTargetBytes = 5_242_880L
+internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
+
+internal val PlayerSideGestureSystemEdgeExclusion = 72.dp
+internal val PlayerSliderOverlayGap = 12.dp
+internal val PlayerTimeRowHeight = 36.dp
+internal val PlayerActionRowHeight = 50.dp
+
+internal fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) =
+ metrics.sliderBottomOffset +
+ metrics.sliderTouchHeight +
+ PlayerTimeRowHeight +
+ PlayerActionRowHeight +
+ PlayerSliderOverlayGap
+
+internal enum class PlayerSideGesture {
+ Brightness,
+ Volume,
+}
+
+internal enum class PlayerSeekDirection {
+ Backward,
+ Forward,
+}
+
+internal enum class PlayerGestureMode {
+ HorizontalSeek,
+ Brightness,
+ Volume,
+}
+
+internal data class PlayerAccumulatedSeekState(
+ val direction: PlayerSeekDirection,
+ val baselinePositionMs: Long,
+ val amountMs: Long,
+)
+
+internal data class PendingPlayerP2pSwitch(
+ val stream: StreamItem,
+ val episode: MetaVideo?,
+ val isAutoPlay: Boolean,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt
new file mode 100644
index 00000000..de46194d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt
@@ -0,0 +1,196 @@
+package com.nuvio.app.features.player
+
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.IntSize
+import kotlin.math.abs
+import kotlin.math.roundToLong
+
+internal fun Modifier.playerSurfaceTapGestures(
+ layoutSize: IntSize,
+ playerControlsLockedState: State,
+ onSurfaceTap: State<(Offset) -> Unit>,
+ onSurfaceDoubleTap: State<(Offset) -> Unit>,
+ activateHoldToSpeedState: State<() -> Unit>,
+ deactivateHoldToSpeedState: State<() -> Unit>,
+ revealLockedOverlayState: State<() -> Unit>,
+): Modifier =
+ pointerInput(layoutSize) {
+ detectTapGestures(
+ onPress = {
+ tryAwaitRelease()
+ deactivateHoldToSpeedState.value()
+ },
+ onTap = { offset -> onSurfaceTap.value(offset) },
+ onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
+ onLongPress = {
+ if (playerControlsLockedState.value) {
+ revealLockedOverlayState.value()
+ } else {
+ activateHoldToSpeedState.value()
+ }
+ },
+ )
+ }
+
+internal fun Modifier.playerSurfaceDragGestures(
+ gestureController: PlayerGestureController?,
+ layoutSize: IntSize,
+ sideGestureSystemEdgeExclusionPx: Float,
+ playerControlsLockedState: State,
+ isHoldToSpeedGestureActiveState: State,
+ currentPositionMsState: State,
+ currentDurationMsState: State,
+ deactivateHoldToSpeedState: State<() -> Unit>,
+ showHorizontalSeekPreviewState: State<(Long, Long) -> Unit>,
+ showBrightnessFeedbackState: State<(Float) -> Unit>,
+ showVolumeFeedbackState: State<(PlayerAudioLevel) -> Unit>,
+ clearLiveGestureFeedbackState: State<() -> Unit>,
+ revealLockedOverlayState: State<() -> Unit>,
+ commitHorizontalSeekState: State<(Long) -> Unit>,
+): Modifier =
+ pointerInput(gestureController, layoutSize, sideGestureSystemEdgeExclusionPx) {
+ awaitEachGesture {
+ val down = awaitFirstDown()
+ if (playerControlsLockedState.value) {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (!change.pressed) break
+ change.consume()
+ }
+ return@awaitEachGesture
+ }
+ val controller = gestureController
+ val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
+ val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
+ val sideGestureEdgeExclusionPx = sideGestureSystemEdgeExclusionPx
+ .coerceAtMost(height * 0.25f)
+ val isInSideGestureSystemEdge =
+ down.position.y <= sideGestureEdgeExclusionPx ||
+ down.position.y >= height - sideGestureEdgeExclusionPx
+ val region = when {
+ isInSideGestureSystemEdge -> null
+ down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness
+ down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume
+ else -> null
+ }
+
+ val initialBrightness = if (region == PlayerSideGesture.Brightness) {
+ controller?.currentBrightness()
+ } else {
+ null
+ }
+ val initialVolume = if (region == PlayerSideGesture.Volume) {
+ controller?.currentVolume()
+ } else {
+ null
+ }
+
+ var totalDx = 0f
+ var totalDy = 0f
+ var gestureMode: PlayerGestureMode? = null
+ var verticalGestureActivationDy = 0f
+ val horizontalSeekBaselineMs = currentPositionMsState.value
+ var horizontalSeekPreviewMs = horizontalSeekBaselineMs
+
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (!change.pressed) break
+
+ val delta = change.position - change.previousPosition
+ totalDx += delta.x
+ totalDy += delta.y
+
+ if (gestureMode == null) {
+ val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
+ val verticalGestureActivationSlop = maxOf(
+ viewConfiguration.touchSlop * PlayerVerticalGestureTouchSlopMultiplier,
+ height * PlayerVerticalGestureMinHeightFraction,
+ )
+ val horizontalDominant =
+ !holdToSpeedActive &&
+ abs(totalDx) > viewConfiguration.touchSlop &&
+ abs(totalDx) > abs(totalDy)
+ val verticalDominant =
+ !holdToSpeedActive &&
+ abs(totalDy) > verticalGestureActivationSlop &&
+ abs(totalDy) > abs(totalDx) * PlayerVerticalGestureDominanceRatio
+
+ gestureMode = when {
+ horizontalDominant -> {
+ deactivateHoldToSpeedState.value()
+ PlayerGestureMode.HorizontalSeek
+ }
+
+ verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> {
+ verticalGestureActivationDy = totalDy
+ PlayerGestureMode.Brightness
+ }
+
+ verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> {
+ verticalGestureActivationDy = totalDy
+ PlayerGestureMode.Volume
+ }
+
+ else -> null
+ }
+
+ if (gestureMode == null) {
+ continue
+ }
+ }
+
+ when (gestureMode) {
+ PlayerGestureMode.HorizontalSeek -> {
+ val sensitivitySeconds = when {
+ currentDurationMsState.value >= 3_600_000L -> 120f
+ currentDurationMsState.value >= 1_800_000L -> 90f
+ else -> 60f
+ }
+ val previewOffsetMs =
+ ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong()
+ val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs
+ horizontalSeekPreviewMs = currentDurationMsState.value
+ .takeIf { it > 0L }
+ ?.let { durationMs ->
+ unclampedPreviewMs.coerceIn(0L, durationMs)
+ }
+ ?: unclampedPreviewMs.coerceAtLeast(0L)
+ showHorizontalSeekPreviewState.value(
+ horizontalSeekPreviewMs,
+ horizontalSeekBaselineMs,
+ )
+ }
+
+ PlayerGestureMode.Brightness -> {
+ val activeTotalDy = totalDy - verticalGestureActivationDy
+ val gestureDeltaFraction =
+ (-activeTotalDy / height) * PlayerVerticalGestureSensitivity
+ controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction)
+ ?.let(showBrightnessFeedbackState.value)
+ }
+
+ PlayerGestureMode.Volume -> {
+ val activeTotalDy = totalDy - verticalGestureActivationDy
+ val gestureDeltaFraction =
+ (-activeTotalDy / height) * PlayerVerticalGestureSensitivity
+ controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction)
+ ?.let(showVolumeFeedbackState.value)
+ }
+ }
+ change.consume()
+ }
+
+ if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) {
+ commitHorizontalSeekState.value(horizontalSeekPreviewMs)
+ clearLiveGestureFeedbackState.value()
+ }
+ }
+ }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt
new file mode 100644
index 00000000..5bd895de
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt
@@ -0,0 +1,167 @@
+package com.nuvio.app.features.player
+
+import com.nuvio.app.features.addons.AddonResource
+import com.nuvio.app.features.addons.ManagedAddon
+import com.nuvio.app.features.addons.enabledAddons
+
+internal fun buildAddonSubtitleFetchKey(
+ addons: List,
+ type: String?,
+ videoId: String?,
+): String? {
+ val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null
+ val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null
+ val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon ->
+ val manifest = addon.manifest ?: return@mapNotNull null
+ val supportsSubtitles = manifest.resources.any { resource ->
+ resource.isCompatibleSubtitleResource(
+ type = normalizedType,
+ videoId = normalizedVideoId,
+ )
+ }
+ if (!supportsSubtitles) return@mapNotNull null
+ "${manifest.id}:${manifest.transportUrl}"
+ }
+
+ if (compatibleSubtitleAddons.isEmpty()) return null
+ return buildString {
+ append(normalizedType)
+ append('|')
+ append(normalizedVideoId)
+ append('|')
+ append(compatibleSubtitleAddons.sorted().joinToString("|"))
+ }
+}
+
+internal fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean {
+ val isSubtitleResource = name.equals("subtitles", ignoreCase = true) ||
+ name.equals("subtitle", ignoreCase = true)
+ if (!isSubtitleResource) return false
+
+ val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type
+ val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) }
+ if (!typeMatches) return false
+
+ return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
+}
+
+internal fun findPreferredTrackIndex(
+ tracks: List,
+ targets: List,
+ language: (T) -> String?,
+): Int {
+ if (targets.isEmpty()) return -1
+ for (target in targets) {
+ val matchIndex = tracks.indexOfFirst { track ->
+ languageMatchesPreference(
+ trackLanguage = language(track),
+ targetLanguage = target,
+ )
+ }
+ if (matchIndex >= 0) {
+ return matchIndex
+ }
+ }
+ return -1
+}
+
+internal fun findPreferredSubtitleTrackIndex(
+ tracks: List,
+ targets: List,
+): Int {
+ if (targets.isEmpty()) return -1
+
+ for ((targetPosition, target) in targets.withIndex()) {
+ val normalizedTarget = normalizeLanguageCode(target) ?: continue
+ if (normalizedTarget == SubtitleLanguageOption.FORCED) {
+ val forcedIndex = tracks.indexOfFirst { it.isForced }
+ if (forcedIndex >= 0) return forcedIndex
+ if (targetPosition == 0) return -1
+ continue
+ }
+
+ val matchIndex = tracks.indexOfFirst { track ->
+ languageMatchesPreference(
+ trackLanguage = track.language,
+ targetLanguage = normalizedTarget,
+ )
+ }
+ if (matchIndex >= 0) return matchIndex
+ }
+
+ return -1
+}
+
+internal fun filterAddonSubtitlesForSettings(
+ subtitles: List,
+ settings: PlayerSettingsUiState,
+ selectedAddonSubtitleId: String?,
+): List {
+ val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
+ settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
+ if (!shouldFilter) return subtitles
+
+ val targets = preferredSubtitleTargetsForSettings(settings)
+ if (targets.isEmpty()) {
+ return subtitles.filter { subtitle ->
+ subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
+ }
+ }
+
+ val filtered = subtitles.filter { subtitle ->
+ subtitle.id == selectedAddonSubtitleId ||
+ subtitle.url == selectedAddonSubtitleId ||
+ targets.any { target ->
+ languageMatchesPreference(
+ trackLanguage = subtitle.language,
+ targetLanguage = target,
+ )
+ }
+ }
+ return filtered
+}
+
+internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List {
+ val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
+ SubtitleLanguageOption.FORCED
+ } else {
+ settings.preferredSubtitleLanguage
+ }
+ return resolvePreferredSubtitleLanguageTargets(
+ preferredSubtitleLanguage = preferredLanguage,
+ secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
+ deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
+ ).filterNot { it == SubtitleLanguageOption.FORCED }
+}
+
+internal fun findPersistedAudioTrackIndex(
+ tracks: List,
+ preference: PersistedPlayerTrackPreference,
+): Int {
+ preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
+ tracks.firstOrNull { it.id == trackId }?.let { return it.index }
+ }
+ preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language ->
+ tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
+ }
+ preference.audioName?.takeIf { it.isNotBlank() }?.let { name ->
+ tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
+ }
+ return -1
+}
+
+internal fun findPersistedSubtitleTrackIndex(
+ tracks: List,
+ preference: PersistedPlayerTrackPreference,
+): Int {
+ preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
+ tracks.firstOrNull { it.id == trackId }?.let { return it.index }
+ }
+ preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language ->
+ tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
+ }
+ preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name ->
+ tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
+ }
+ return -1
+}
diff --git a/vendor/TorrServer b/vendor/TorrServer
new file mode 160000
index 00000000..b79dbaf9
--- /dev/null
+++ b/vendor/TorrServer
@@ -0,0 +1 @@
+Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a
From 24464654947ba6339c59ea400d534430fe60eacf Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:17:53 +0530
Subject: [PATCH 4/8] feat: remember last used profile
---
.../composeResources/values/strings.xml | 5 ++
.../commonMain/kotlin/com/nuvio/app/App.kt | 53 +++++++++++++++++--
.../app/features/profiles/ProfileModels.kt | 2 +
.../features/profiles/ProfileRepository.kt | 20 ++++++-
.../features/settings/AdvancedSettingsPage.kt | 31 +++++++++++
.../app/features/settings/SettingsModels.kt | 6 +++
.../app/features/settings/SettingsRootPage.kt | 12 +++++
.../app/features/settings/SettingsScreen.kt | 18 +++++++
.../app/features/settings/SettingsSearch.kt | 17 ++++++
9 files changed, 159 insertions(+), 5 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 0c34f27f..68a45e55 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -404,6 +404,7 @@
General
Account
Addons
+ Advanced
Layout
Content & Discovery
Continue Watching
@@ -425,6 +426,7 @@
ABOUT
Account and sync status
ACCOUNT
+ Startup and profile behavior.
Home structure and poster styles
Download latest release
Check for updates
@@ -441,6 +443,9 @@
No settings found.
Search settings...
RESULTS
+ STARTUP
+ Remember Last Profile
+ Start with the last selected profile when it does not have a PIN lock.
APP LICENSE
DATA & SERVICES
PLAYBACK LICENSE
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 1f53c387..d6db9960 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -413,6 +413,20 @@ fun App() {
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
+ fun rememberedStartupProfile(profiles: List): NuvioProfile? {
+ val currentProfileState = ProfileRepository.state.value
+ if (
+ !currentProfileState.rememberLastProfileEnabled ||
+ !currentProfileState.hasEverSelectedProfile
+ ) {
+ return null
+ }
+
+ return profiles
+ .find { it.profileIndex == ProfileRepository.activeProfileId }
+ ?.takeUnless { it.pinEnabled }
+ }
+
fun enterProfileGate(profiles: List, syncOnEnter: Boolean) {
if (profiles.isEmpty()) {
autoSkipProfileSelection = true
@@ -420,9 +434,23 @@ fun App() {
return
}
+ rememberedStartupProfile(profiles)?.let { profile ->
+ ProfileRepository.selectProfile(profile.profileIndex)
+ if (syncOnEnter) {
+ SyncManager.pullAllForProfile(profile.profileIndex)
+ }
+ gateScreen = AppGateScreen.Main.name
+ autoSkipProfileSelection = false
+ return
+ }
+
autoSkipProfileSelection = true
if (profiles.size == 1) {
val onlyProfile = profiles.first()
+ if (onlyProfile.pinEnabled) {
+ gateScreen = AppGateScreen.ProfileSelection.name
+ return
+ }
ProfileRepository.selectProfile(onlyProfile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
@@ -473,13 +501,32 @@ fun App() {
ProfileRepository.pullProfiles()
}
- LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) {
+ LaunchedEffect(
+ gateScreen,
+ autoSkipProfileSelection,
+ profileState.profiles,
+ profileState.hasEverSelectedProfile,
+ profileState.rememberLastProfileEnabled,
+ profileState.activeProfile?.profileIndex,
+ profileState.activeProfile?.pinEnabled,
+ ) {
if (
autoSkipProfileSelection &&
- gateScreen == AppGateScreen.ProfileSelection.name &&
- profileState.profiles.size == 1
+ gateScreen == AppGateScreen.ProfileSelection.name
) {
+ rememberedStartupProfile(profileState.profiles)?.let { profile ->
+ ProfileRepository.selectProfile(profile.profileIndex)
+ SyncManager.pullAllForProfile(profile.profileIndex)
+ gateScreen = AppGateScreen.Main.name
+ autoSkipProfileSelection = false
+ return@LaunchedEffect
+ }
+
+ if (profileState.profiles.size != 1) return@LaunchedEffect
+
val onlyProfile = profileState.profiles.first()
+ if (onlyProfile.pinEnabled) return@LaunchedEffect
+
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
index f36aee81..ccca24b3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
@@ -43,6 +43,8 @@ data class ProfileState(
val profiles: List = emptyList(),
val activeProfile: NuvioProfile? = null,
val isLoaded: Boolean = false,
+ val hasEverSelectedProfile: Boolean = false,
+ val rememberLastProfileEnabled: Boolean = false,
)
@Serializable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index 18057320..ab6b5b2b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -53,6 +53,8 @@ import org.jetbrains.compose.resources.getString
private data class StoredProfilePayload(
val userId: String,
val activeProfileIndex: Int = 1,
+ val hasEverSelectedProfile: Boolean = false,
+ val rememberLastProfileEnabled: Boolean = false,
val profiles: List = emptyList(),
)
@@ -70,6 +72,13 @@ object ProfileRepository {
val activeProfileId: Int get() = activeProfileIndex
+ fun setRememberLastProfileEnabled(enabled: Boolean) {
+ if (_state.value.rememberLastProfileEnabled == enabled) return
+
+ _state.value = _state.value.copy(rememberLastProfileEnabled = enabled)
+ persist()
+ }
+
fun loadCachedProfiles(): Boolean {
val stored = decodeStoredPayload() ?: return false
loadedCacheForUserId = stored.userId
@@ -134,8 +143,10 @@ object ProfileRepository {
fun selectProfile(profileIndex: Int) {
activeProfileIndex = profileIndex
+ val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex }
_state.value = _state.value.copy(
- activeProfile = _state.value.profiles.find { it.profileIndex == profileIndex },
+ activeProfile = selectedProfile,
+ hasEverSelectedProfile = selectedProfile != null || _state.value.hasEverSelectedProfile,
)
persist()
WatchedRepository.onProfileChanged(profileIndex)
@@ -402,6 +413,8 @@ object ProfileRepository {
profiles = profiles,
activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(),
isLoaded = profiles.isNotEmpty(),
+ hasEverSelectedProfile = stored.hasEverSelectedProfile,
+ rememberLastProfileEnabled = stored.rememberLastProfileEnabled,
)
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex }
syncPinCache(profiles)
@@ -490,12 +503,15 @@ object ProfileRepository {
private fun persist() {
val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return
+ val state = _state.value
ProfileStorage.savePayload(
json.encodeToString(
StoredProfilePayload(
userId = authState.userId,
activeProfileIndex = activeProfileIndex,
- profiles = _state.value.profiles,
+ hasEverSelectedProfile = state.hasEverSelectedProfile,
+ rememberLastProfileEnabled = state.rememberLastProfileEnabled,
+ profiles = state.profiles,
),
),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
new file mode 100644
index 00000000..57af823a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
@@ -0,0 +1,31 @@
+package com.nuvio.app.features.settings
+
+import androidx.compose.foundation.lazy.LazyListScope
+import com.nuvio.app.features.profiles.ProfileRepository
+import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile
+import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile_description
+import nuvio.composeapp.generated.resources.settings_advanced_section_startup
+import org.jetbrains.compose.resources.stringResource
+
+internal fun LazyListScope.advancedSettingsContent(
+ isTablet: Boolean,
+ rememberLastProfileEnabled: Boolean,
+) {
+ item {
+ SettingsSection(
+ title = stringResource(Res.string.settings_advanced_section_startup),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_advanced_remember_last_profile),
+ description = stringResource(Res.string.settings_advanced_remember_last_profile_description),
+ checked = rememberLastProfileEnabled,
+ isTablet = isTablet,
+ onCheckedChange = ProfileRepository::setRememberLastProfileEnabled,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
index 006d8768..7baa1761 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
@@ -11,6 +11,7 @@ import nuvio.composeapp.generated.resources.compose_settings_category_about
import nuvio.composeapp.generated.resources.compose_settings_category_general
import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.compose_settings_page_addons
+import nuvio.composeapp.generated.resources.compose_settings_page_advanced
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
@@ -81,6 +82,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Root,
),
+ Advanced(
+ titleRes = Res.string.compose_settings_page_advanced,
+ category = SettingsCategory.General,
+ parentPage = Root,
+ ),
Notifications(
titleRes = Res.string.compose_settings_page_notifications,
category = SettingsCategory.General,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
index 9ce3a1ff..a7f8b440 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Style
+import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
@@ -25,6 +26,7 @@ import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_about_made_with
import nuvio.composeapp.generated.resources.compose_about_version_format
import nuvio.composeapp.generated.resources.compose_settings_page_account
+import nuvio.composeapp.generated.resources.compose_settings_page_advanced
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
@@ -48,6 +50,7 @@ import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile
import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description
import nuvio.composeapp.generated.resources.compose_settings_root_about_section
import nuvio.composeapp.generated.resources.compose_settings_root_account_section
+import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.settings_playback_subtitle
@@ -60,6 +63,7 @@ internal fun LazyListScope.settingsRootContent(
onPlaybackClick: () -> Unit,
onStreamsClick: () -> Unit,
onAppearanceClick: () -> Unit,
+ onAdvancedClick: () -> Unit,
onNotificationsClick: () -> Unit,
onContentDiscoveryClick: () -> Unit,
onIntegrationsClick: () -> Unit,
@@ -125,6 +129,14 @@ internal fun LazyListScope.settingsRootContent(
onClick = onAppearanceClick,
)
SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.compose_settings_page_advanced),
+ description = stringResource(Res.string.compose_settings_root_advanced_description),
+ icon = Icons.Rounded.Tune,
+ isTablet = isTablet,
+ onClick = onAdvancedClick,
+ )
+ SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_content_discovery),
description = stringResource(Res.string.compose_settings_root_content_discovery_description),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index 88cf0b01..4c237106 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -69,6 +69,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState
import com.nuvio.app.features.player.PlayerSettingsRepository
+import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
@@ -194,6 +195,9 @@ fun SettingsScreen(
EpisodeReleaseNotificationsRepository.ensureLoaded()
EpisodeReleaseNotificationsRepository.uiState
}.collectAsStateWithLifecycle()
+ val profileSettingsState by remember {
+ ProfileRepository.state
+ }.collectAsStateWithLifecycle()
LaunchedEffect(homescreenCatalogRefreshKey) {
if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect
@@ -258,6 +262,7 @@ fun SettingsScreen(
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
+ rememberLastProfileEnabled = profileSettingsState.rememberLastProfileEnabled,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@@ -307,6 +312,7 @@ fun SettingsScreen(
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
+ rememberLastProfileEnabled = profileSettingsState.rememberLastProfileEnabled,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@@ -366,6 +372,7 @@ private fun MobileSettingsScreen(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
+ rememberLastProfileEnabled: Boolean,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@@ -496,6 +503,7 @@ private fun MobileSettingsScreen(
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onStreamsClick = { onPageChange(SettingsPage.Streams) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
+ onAdvancedClick = { onPageChange(SettingsPage.Advanced) },
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
@@ -552,6 +560,10 @@ private fun MobileSettingsScreen(
onContinueWatchingClick = onContinueWatchingClick,
onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) },
)
+ SettingsPage.Advanced -> advancedSettingsContent(
+ isTablet = false,
+ rememberLastProfileEnabled = rememberLastProfileEnabled,
+ )
SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = false,
uiState = episodeReleaseNotificationsUiState,
@@ -684,6 +696,7 @@ private fun TabletSettingsScreen(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
+ rememberLastProfileEnabled: Boolean,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@@ -869,6 +882,7 @@ private fun TabletSettingsScreen(
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onStreamsClick = { openInlinePage(SettingsPage.Streams) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
+ onAdvancedClick = { openInlinePage(SettingsPage.Advanced) },
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
@@ -928,6 +942,10 @@ private fun TabletSettingsScreen(
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) },
)
+ SettingsPage.Advanced -> advancedSettingsContent(
+ isTablet = true,
+ rememberLastProfileEnabled = rememberLastProfileEnabled,
+ )
SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = true,
uiState = episodeReleaseNotificationsUiState,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 0de4be7c..b604f5bd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -89,6 +89,7 @@ internal fun settingsSearchEntries(
val accountPage = stringResource(Res.string.compose_settings_page_account)
val traktPage = stringResource(Res.string.compose_settings_page_trakt)
val layoutPage = stringResource(Res.string.compose_settings_page_appearance)
+ val advancedPage = stringResource(Res.string.compose_settings_page_advanced)
val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery)
val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title)
val playbackPage = stringResource(Res.string.compose_settings_page_playback)
@@ -207,6 +208,13 @@ internal fun settingsSearchEntries(
description = stringResource(Res.string.compose_settings_root_appearance_description),
icon = Icons.Rounded.Palette,
)
+ addPage(
+ page = SettingsPage.Advanced,
+ key = "advanced",
+ title = advancedPage,
+ description = stringResource(Res.string.compose_settings_root_advanced_description),
+ icon = Icons.Rounded.Tune,
+ )
addPage(
page = SettingsPage.ContentDiscovery,
key = "content-discovery",
@@ -368,6 +376,15 @@ internal fun settingsSearchEntries(
section = stringResource(Res.string.settings_appearance_section_display),
icon = Icons.Rounded.Language,
)
+ addRow(
+ page = SettingsPage.Advanced,
+ key = "remember-last-profile",
+ title = stringResource(Res.string.settings_advanced_remember_last_profile),
+ description = stringResource(Res.string.settings_advanced_remember_last_profile_description),
+ pageLabel = advancedPage,
+ section = stringResource(Res.string.settings_advanced_section_startup),
+ icon = Icons.Rounded.Tune,
+ )
addPage(
page = SettingsPage.ContinueWatching,
key = "continue-watching",
From d7e1da6b47794a060bcdf3b5db1a9fb0703e0abd Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:23:40 +0530
Subject: [PATCH 5/8] feat(settings): add advanced settings section
---
.../composeResources/values/strings.xml | 3 +-
.../app/features/settings/SettingsModels.kt | 4 ++-
.../app/features/settings/SettingsRootPage.kt | 28 +++++++++++++------
.../app/features/settings/SettingsScreen.kt | 1 +
.../app/features/settings/SettingsSearch.kt | 3 ++
5 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 68a45e55..f9574613 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -427,6 +427,7 @@
Account and sync status
ACCOUNT
Startup and profile behavior.
+ ADVANCED
Home structure and poster styles
Download latest release
Check for updates
@@ -445,7 +446,7 @@
RESULTS
STARTUP
Remember Last Profile
- Start with the last selected profile when it does not have a PIN lock.
+ Remember last selected profile at startup
APP LICENSE
DATA & SERVICES
PLAYBACK LICENSE
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
index 7baa1761..1fc6ca30 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Tune
import androidx.compose.ui.graphics.vector.ImageVector
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_category_about
@@ -40,6 +41,7 @@ internal enum class SettingsCategory(
Account(Res.string.settings_account, Icons.Rounded.AccountCircle),
General(Res.string.compose_settings_category_general, Icons.Rounded.Settings),
About(Res.string.compose_settings_category_about, Icons.Rounded.Info),
+ Advanced(Res.string.compose_settings_page_advanced, Icons.Rounded.Tune),
}
internal enum class SettingsPage(
@@ -84,7 +86,7 @@ internal enum class SettingsPage(
),
Advanced(
titleRes = Res.string.compose_settings_page_advanced,
- category = SettingsCategory.General,
+ category = SettingsCategory.Advanced,
parentPage = Root,
),
Notifications(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
index a7f8b440..f557f762 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt
@@ -51,6 +51,7 @@ import nuvio.composeapp.generated.resources.compose_settings_root_trakt_descript
import nuvio.composeapp.generated.resources.compose_settings_root_about_section
import nuvio.composeapp.generated.resources.compose_settings_root_account_section
import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description
+import nuvio.composeapp.generated.resources.compose_settings_root_advanced_section
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.settings_playback_subtitle
@@ -77,6 +78,7 @@ internal fun LazyListScope.settingsRootContent(
showAccountSection: Boolean = true,
showGeneralSection: Boolean = true,
showAboutSection: Boolean = true,
+ showAdvancedSection: Boolean = true,
) {
if (showAccountSection) {
item {
@@ -129,14 +131,6 @@ internal fun LazyListScope.settingsRootContent(
onClick = onAppearanceClick,
)
SettingsGroupDivider(isTablet = isTablet)
- SettingsNavigationRow(
- title = stringResource(Res.string.compose_settings_page_advanced),
- description = stringResource(Res.string.compose_settings_root_advanced_description),
- icon = Icons.Rounded.Tune,
- isTablet = isTablet,
- onClick = onAdvancedClick,
- )
- SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_content_discovery),
description = stringResource(Res.string.compose_settings_root_content_discovery_description),
@@ -224,6 +218,24 @@ internal fun LazyListScope.settingsRootContent(
}
}
}
+ if (showAdvancedSection) {
+ item {
+ SettingsSection(
+ title = stringResource(Res.string.compose_settings_root_advanced_section),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ SettingsNavigationRow(
+ title = stringResource(Res.string.compose_settings_page_advanced),
+ description = stringResource(Res.string.compose_settings_root_advanced_description),
+ icon = Icons.Rounded.Tune,
+ isTablet = isTablet,
+ onClick = onAdvancedClick,
+ )
+ }
+ }
+ }
+ }
item {
androidx.compose.foundation.layout.Column(
modifier = Modifier
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index 4c237106..b356e986 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -896,6 +896,7 @@ private fun TabletSettingsScreen(
showAccountSection = activeCategory == SettingsCategory.Account,
showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About,
+ showAdvancedSection = activeCategory == SettingsCategory.Advanced,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index b604f5bd..045d6522 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -85,6 +85,7 @@ internal fun settingsSearchEntries(
val accountCategory = stringResource(SettingsCategory.Account.labelRes)
val generalCategory = stringResource(SettingsCategory.General.labelRes)
val aboutCategory = stringResource(SettingsCategory.About.labelRes)
+ val advancedCategory = stringResource(SettingsCategory.Advanced.labelRes)
val accountPage = stringResource(Res.string.compose_settings_page_account)
val traktPage = stringResource(Res.string.compose_settings_page_trakt)
@@ -213,6 +214,7 @@ internal fun settingsSearchEntries(
key = "advanced",
title = advancedPage,
description = stringResource(Res.string.compose_settings_root_advanced_description),
+ category = advancedCategory,
icon = Icons.Rounded.Tune,
)
addPage(
@@ -383,6 +385,7 @@ internal fun settingsSearchEntries(
description = stringResource(Res.string.settings_advanced_remember_last_profile_description),
pageLabel = advancedPage,
section = stringResource(Res.string.settings_advanced_section_startup),
+ category = advancedCategory,
icon = Icons.Rounded.Tune,
)
addPage(
From ab5415f4772a65fb2c7149927fadd2638c9b457c Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:31:32 +0530
Subject: [PATCH 6/8] feat: clear cw cache
---
...ntinueWatchingEnrichmentStorage.android.kt | 7 ++++
.../composeResources/values/strings.xml | 4 +++
.../features/settings/AdvancedSettingsPage.kt | 34 +++++++++++++++++++
.../app/features/settings/SettingsSearch.kt | 10 ++++++
.../ContinueWatchingEnrichmentCache.kt | 5 +++
.../ContinueWatchingEnrichmentStorage.kt | 1 +
.../ContinueWatchingEnrichmentStorage.ios.kt | 4 +++
7 files changed, 65 insertions(+)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt
index 0fc4827d..db30cade 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt
@@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage {
?.putString(key, payload)
?.apply()
}
+
+ actual fun removePayload(key: String) {
+ preferences
+ ?.edit()
+ ?.remove(key)
+ ?.apply()
+ }
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f9574613..efbfeaf6 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -445,8 +445,12 @@
Search settings...
RESULTS
STARTUP
+ CACHE
Remember Last Profile
Remember last selected profile at startup
+ Clear Continue Watching Cache
+ Remove cached thumbnails, titles, and enrichment data for Continue Watching
+ Cache cleared
APP LICENSE
DATA & SERVICES
PLAYBACK LICENSE
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
index 57af823a..fc9f77cc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
@@ -1,10 +1,19 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import com.nuvio.app.features.profiles.ProfileRepository
+import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache
+import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done
+import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_subtitle
import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile
import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile_description
+import nuvio.composeapp.generated.resources.settings_advanced_section_cache
import nuvio.composeapp.generated.resources.settings_advanced_section_startup
import org.jetbrains.compose.resources.stringResource
@@ -28,4 +37,29 @@ internal fun LazyListScope.advancedSettingsContent(
}
}
}
+ item {
+ SettingsSection(
+ title = stringResource(Res.string.settings_advanced_section_cache),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ var cleared by rememberSaveable { mutableStateOf(false) }
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_advanced_clear_cw_cache),
+ description = if (cleared) {
+ stringResource(Res.string.settings_advanced_clear_cw_cache_done)
+ } else {
+ stringResource(Res.string.settings_advanced_clear_cw_cache_subtitle)
+ },
+ isTablet = isTablet,
+ onClick = {
+ if (!cleared) {
+ ContinueWatchingEnrichmentCache.clearAll()
+ cleared = true
+ }
+ },
+ )
+ }
+ }
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 045d6522..d98da512 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -388,6 +388,16 @@ internal fun settingsSearchEntries(
category = advancedCategory,
icon = Icons.Rounded.Tune,
)
+ addRow(
+ page = SettingsPage.Advanced,
+ key = "clear-cw-cache",
+ title = stringResource(Res.string.settings_advanced_clear_cw_cache),
+ description = stringResource(Res.string.settings_advanced_clear_cw_cache_subtitle),
+ pageLabel = advancedPage,
+ section = stringResource(Res.string.settings_advanced_section_cache),
+ category = advancedCategory,
+ icon = Icons.Rounded.Tune,
+ )
addPage(
page = SettingsPage.ContinueWatching,
key = "continue-watching",
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
index fbdf59c0..00226f24 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
@@ -95,6 +95,11 @@ internal object ContinueWatchingEnrichmentCache {
lastPayloadHash = payloadHash
}
+ fun clearAll() {
+ ContinueWatchingEnrichmentStorage.removePayload(ProfileScopedKey.of(storageKey))
+ lastPayloadHash = null
+ }
+
private fun loadPayload(): CachedEnrichmentPayload? {
val raw = ContinueWatchingEnrichmentStorage.loadPayload(ProfileScopedKey.of(storageKey))
?: return null
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt
index 24d6791d..42a6df4b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt
@@ -3,4 +3,5 @@ package com.nuvio.app.features.watchprogress
internal expect object ContinueWatchingEnrichmentStorage {
fun loadPayload(key: String): String?
fun savePayload(key: String, payload: String)
+ fun removePayload(key: String)
}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt
index 7ce272e4..9677d507 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt
@@ -9,4 +9,8 @@ actual object ContinueWatchingEnrichmentStorage {
actual fun savePayload(key: String, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = key)
}
+
+ actual fun removePayload(key: String) {
+ NSUserDefaults.standardUserDefaults.removeObjectForKey(key)
+ }
}
From 9b3298fe2cfa846e38eca76aeaf86ccc32c717e7 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:56:23 +0530
Subject: [PATCH 7/8] feat: trakt as a source for morelikethis
---
.../composeResources/values/strings.xml | 8 +
.../app/features/details/MetaDetailsModels.kt | 6 +
.../features/details/MetaDetailsRepository.kt | 115 +++++-
.../app/features/details/MetaDetailsScreen.kt | 32 ++
.../components/DetailPosterRailSection.kt | 58 +++-
.../app/features/settings/SettingsSearch.kt | 1 +
.../features/settings/TraktSettingsPage.kt | 88 +++++
.../app/features/tmdb/TmdbMetadataService.kt | 7 +-
.../features/trakt/TraktRelatedRepository.kt | 327 ++++++++++++++++++
.../features/trakt/TraktSettingsRepository.kt | 29 ++
.../trakt/TraktSettingsRepositoryTest.kt | 35 ++
11 files changed, 683 insertions(+), 23 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index efbfeaf6..c8fe87a7 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1008,6 +1008,12 @@
Nuvio Sync
Watch progress source set to Trakt
Watch progress source set to Nuvio Sync
+ More Like This source
+ Choose where recommendations come from on detail pages
+ More Like This source
+ Select the source for recommendations shown on detail pages.
+ Trakt
+ TMDB
Continue Watching Window
Trakt history considered for continue watching
Continue Watching Window
@@ -1133,6 +1139,8 @@
Director
Failed to load
More Like This
+ Powered by TMDB
+ Powered by Trakt
Seasons
This addon returned videos for the series, but none included season or episode numbers.
This addon did not provide episode metadata for this series.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt
index 3ac52ded..de32bc32 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt
@@ -31,6 +31,7 @@ data class MetaDetails(
val website: String? = null,
val hasScheduledVideos: Boolean = false,
val moreLikeThis: List = emptyList(),
+ val moreLikeThisSource: MoreLikeThisSource? = null,
val collectionName: String? = null,
val collectionItems: List = emptyList(),
val trailers: List = emptyList(),
@@ -38,6 +39,11 @@ data class MetaDetails(
val videos: List = emptyList(),
)
+enum class MoreLikeThisSource {
+ TMDB,
+ TRAKT,
+}
+
data class MetaExternalRating(
val source: String,
val value: Double,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt
index fcb432b9..b1654a89 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt
@@ -13,6 +13,11 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
+import com.nuvio.app.features.trakt.TraktAuthRepository
+import com.nuvio.app.features.trakt.TraktConnectionMode
+import com.nuvio.app.features.trakt.TraktRelatedRepository
+import com.nuvio.app.features.trakt.TraktSettingsRepository
+import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -58,7 +63,7 @@ object MetaDetailsRepository {
}
val cachedBaseMeta = cachedEntry.baseMeta
- if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
+ if (!shouldEnrichForMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey
return
@@ -81,6 +86,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = cachedBaseMeta,
fallbackItemId = id,
+ fallbackItemType = type,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
@@ -116,6 +122,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
+ fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@@ -139,6 +146,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = result,
fallbackItemId = metaLookupId,
+ fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@@ -152,6 +160,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
+ fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@@ -300,13 +309,14 @@ object MetaDetailsRepository {
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
+ fallbackItemType: String,
mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings,
metaScreenSettingsFingerprint: String,
) {
val cachedEntry = CachedMetaEntry(baseMeta = meta)
cachedMetaByRequestKey[requestKey] = cachedEntry
- if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
+ if (!shouldEnrichForMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
activeRequestKey = requestKey
return
@@ -321,6 +331,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = meta,
fallbackItemId = fallbackItemId,
+ fallbackItemType = fallbackItemType,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
@@ -337,16 +348,22 @@ object MetaDetailsRepository {
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
+ fallbackItemType: String,
settings: com.nuvio.app.features.mdblist.MdbListSettings,
settingsFingerprint: String,
): MetaDetails {
- val enrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) {
+ val mdbListEnrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) {
MdbListMetadataService.enrichMeta(
meta = meta,
fallbackItemId = fallbackItemId,
settings = settings,
)
} ?: meta
+ val enrichedMeta = applyMoreLikeThisSource(
+ meta = mdbListEnrichedMeta,
+ fallbackItemId = fallbackItemId,
+ fallbackItemType = fallbackItemType,
+ )
cachedMetaByRequestKey[requestKey] = cachedMetaByRequestKey[requestKey]
?.copy(
@@ -362,6 +379,49 @@ object MetaDetailsRepository {
return enrichedMeta
}
+ private suspend fun applyMoreLikeThisSource(
+ meta: MetaDetails,
+ fallbackItemId: String,
+ fallbackItemType: String,
+ ): MetaDetails {
+ TraktSettingsRepository.ensureLoaded()
+ TraktAuthRepository.ensureLoaded()
+ TmdbSettingsRepository.ensureLoaded()
+
+ val traktSettings = TraktSettingsRepository.uiState.value
+ val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
+ val shouldUseTrakt = shouldUseTraktMoreLikeThis(
+ isAuthenticated = isTraktAuthenticated,
+ source = traktSettings.moreLikeThisSource,
+ ) && supportsMoreLikeThis(meta, fallbackItemType)
+
+ if (shouldUseTrakt) {
+ val items = runCatching {
+ TraktRelatedRepository.getRelated(
+ meta = meta,
+ fallbackItemId = fallbackItemId,
+ fallbackItemType = fallbackItemType,
+ )
+ }.onFailure { error ->
+ log.w { "Failed to load Trakt related titles for ${meta.id}: ${error.message}" }
+ }.getOrDefault(emptyList())
+
+ return meta.copy(
+ moreLikeThis = items,
+ moreLikeThisSource = MoreLikeThisSource.TRAKT.takeIf { items.isNotEmpty() },
+ )
+ }
+
+ val tmdbSettings = TmdbSettingsRepository.snapshot()
+ if (!tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis) {
+ return meta.copy(moreLikeThis = emptyList(), moreLikeThisSource = null)
+ }
+
+ return meta.copy(
+ moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { meta.moreLikeThis.isNotEmpty() },
+ )
+ }
+
private fun shouldFetchMdbListOnMetaScreen(
meta: MetaDetails,
fallbackItemId: String,
@@ -372,18 +432,63 @@ object MetaDetailsRepository {
settings = settings,
)
+ private fun shouldEnrichForMetaScreen(
+ meta: MetaDetails,
+ fallbackItemId: String,
+ settings: com.nuvio.app.features.mdblist.MdbListSettings,
+ ): Boolean {
+ if (shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, settings)) return true
+ return shouldApplyMoreLikeThisSource(meta)
+ }
+
+ private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean {
+ TraktSettingsRepository.ensureLoaded()
+ TraktAuthRepository.ensureLoaded()
+ TmdbSettingsRepository.ensureLoaded()
+
+ val traktSettings = TraktSettingsRepository.uiState.value
+ val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
+ val tmdbSettings = TmdbSettingsRepository.snapshot()
+ return shouldUseTraktMoreLikeThis(
+ isAuthenticated = isTraktAuthenticated,
+ source = traktSettings.moreLikeThisSource,
+ ) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty()
+ }
+
private fun buildMetaScreenSettingsFingerprint(
settings: com.nuvio.app.features.mdblist.MdbListSettings,
): String {
+ TraktSettingsRepository.ensureLoaded()
+ TraktAuthRepository.ensureLoaded()
+ TmdbSettingsRepository.ensureLoaded()
val providers = settings.enabledProvidersInPriorityOrder().joinToString(",")
- return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
+ val traktSettings = TraktSettingsRepository.uiState.value
+ val traktAuthMode = TraktAuthRepository.uiState.value.mode
+ val tmdbSettings = TmdbSettingsRepository.snapshot()
+ return buildString {
+ append("${settings.enabled}:${settings.apiKey.trim()}:$providers")
+ append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode")
+ append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}")
+ }
}
+ private fun supportsMoreLikeThis(meta: MetaDetails, fallbackItemType: String): Boolean =
+ normalizeMoreLikeThisType(meta.type) != null || normalizeMoreLikeThisType(fallbackItemType) != null
+
+ private fun normalizeMoreLikeThisType(value: String?): String? =
+ when (value?.trim()?.lowercase()) {
+ "movie", "film" -> "movie"
+ "series", "show", "tv", "tvshow" -> "series"
+ else -> null
+ }
+
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val todayIsoDate = CurrentDateProvider.todayIsoDate()
+ val releasedMoreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate)
return copy(
- moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
+ moreLikeThis = releasedMoreLikeThis,
+ moreLikeThisSource = moreLikeThisSource.takeIf { releasedMoreLikeThis.isNotEmpty() },
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index 18c1b4b3..1e48bb62 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
@@ -89,6 +89,7 @@ import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
+import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview
@@ -96,6 +97,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
+import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
@@ -140,6 +142,14 @@ fun MetaDetailsScreen(
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
+ val traktSettingsUiState by remember {
+ TraktSettingsRepository.ensureLoaded()
+ TraktSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+ val tmdbSettingsUiState by remember {
+ TmdbSettingsRepository.ensureLoaded()
+ TmdbSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
@@ -237,6 +247,22 @@ fun MetaDetailsScreen(
}
}
+ LaunchedEffect(
+ type,
+ id,
+ displayedMeta?.id,
+ uiState.isLoading,
+ traktSettingsUiState.moreLikeThisSource,
+ traktAuthUiState.mode,
+ tmdbSettingsUiState.enabled,
+ tmdbSettingsUiState.useMoreLikeThis,
+ tmdbSettingsUiState.language,
+ ) {
+ if (displayedMeta != null && !uiState.isLoading) {
+ MetaDetailsRepository.load(type, id)
+ }
+ }
+
LaunchedEffect(networkStatusUiState.condition, displayedMeta, uiState.isLoading, type, id) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
@@ -1417,11 +1443,17 @@ private fun ConfiguredMetaSections(
}
MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) {
+ val sourceLabel = when (meta.moreLikeThisSource) {
+ MoreLikeThisSource.TMDB -> stringResource(Res.string.detail_more_like_this_powered_by_tmdb)
+ MoreLikeThisSource.TRAKT -> stringResource(Res.string.detail_more_like_this_powered_by_trakt)
+ null -> null
+ }
DetailPosterRailSection(
title = stringResource(Res.string.details_more_like_this),
items = meta.moreLikeThis,
watchedKeys = watchedKeys,
showHeader = showHeader,
+ sourceLabel = sourceLabel,
onPosterClick = onOpenMeta,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt
index 856f442c..58c2269f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt
@@ -1,8 +1,15 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioShelfSection
@@ -19,28 +26,45 @@ fun DetailPosterRailSection(
modifier: Modifier = Modifier,
showHeader: Boolean = true,
headerHorizontalPadding: Dp = 0.dp,
+ sourceLabel: String? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
if (items.isEmpty()) return
- NuvioShelfSection(
- title = if (showHeader) title else "",
- entries = items,
- modifier = modifier,
- headerHorizontalPadding = headerHorizontalPadding,
- rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
- showHeaderAccent = false,
- key = { item -> item.stableKey() },
- ) { item ->
- HomePosterCard(
- item = item,
- isWatched = WatchingState.isPosterWatched(
- watchedKeys = watchedKeys,
+ Column(modifier = modifier.fillMaxWidth()) {
+ NuvioShelfSection(
+ title = if (showHeader) title else "",
+ entries = items,
+ headerHorizontalPadding = headerHorizontalPadding,
+ rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
+ showHeaderAccent = false,
+ key = { item -> item.stableKey() },
+ ) { item ->
+ HomePosterCard(
item = item,
- ),
- onClick = onPosterClick?.let { { it(item) } },
- onLongClick = onPosterLongClick?.let { { it(item) } },
- )
+ isWatched = WatchingState.isPosterWatched(
+ watchedKeys = watchedKeys,
+ item = item,
+ ),
+ onClick = onPosterClick?.let { { it(item) } },
+ onLongClick = onPosterLongClick?.let { { it(item) } },
+ )
+ }
+
+ sourceLabel
+ ?.takeIf { it.isNotBlank() }
+ ?.let { label ->
+ Text(
+ text = label,
+ modifier = Modifier
+ .align(Alignment.End)
+ .padding(end = headerHorizontalPadding, top = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index d98da512..796cca62 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -809,6 +809,7 @@ internal fun settingsSearchEntries(
PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)),
PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)),
PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)),
+ PlaybackSearchRow("trakt-more-like-this-source", stringResource(Res.string.trakt_more_like_this_source_title), stringResource(Res.string.trakt_more_like_this_source_subtitle)),
).forEach { row ->
addRow(
page = SettingsPage.TraktAuthentication,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
index 198b3123..ac981558 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
@@ -43,6 +43,7 @@ import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
+import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.trakt.WatchProgressSource
@@ -82,6 +83,12 @@ import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
import nuvio.composeapp.generated.resources.trakt_library_source_title
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb
+import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
@@ -148,11 +155,13 @@ private fun TraktFeatureRows(
var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
+ var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) }
var statusMessage by rememberSaveable { mutableStateOf(null) }
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
+ val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource)
val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected)
@@ -189,6 +198,14 @@ private fun TraktFeatureRows(
isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange,
)
+ SettingsGroupDivider(isTablet = isTablet)
+ TraktSettingsActionRow(
+ title = stringResource(Res.string.trakt_more_like_this_source_title),
+ description = stringResource(Res.string.trakt_more_like_this_source_subtitle),
+ value = moreLikeThisSourceValue,
+ isTablet = isTablet,
+ onClick = { showMoreLikeThisSourceDialog = true },
+ )
statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
SettingsGroupDivider(isTablet = isTablet)
TraktInfoRow(
@@ -239,6 +256,17 @@ private fun TraktFeatureRows(
onDismiss = { showContinueWatchingWindowDialog = false },
)
}
+
+ if (showMoreLikeThisSourceDialog) {
+ MoreLikeThisSourceDialog(
+ selectedSource = settingsUiState.moreLikeThisSource,
+ onSourceSelected = { source ->
+ TraktSettingsRepository.setMoreLikeThisSource(source)
+ showMoreLikeThisSourceDialog = false
+ },
+ onDismiss = { showMoreLikeThisSourceDialog = false },
+ )
+ }
}
@Composable
@@ -322,6 +350,13 @@ private fun watchProgressSourceLabel(source: WatchProgressSource): String =
WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
}
+@Composable
+private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String =
+ when (source) {
+ MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt)
+ MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb)
+ }
+
@Composable
private fun continueWatchingDaysCapLabel(daysCap: Int): String {
val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
@@ -494,6 +529,59 @@ private fun ContinueWatchingWindowDialog(
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun MoreLikeThisSourceDialog(
+ selectedSource: MoreLikeThisSourcePreference,
+ onSourceSelected: (MoreLikeThisSourcePreference) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.trakt_more_like_this_source_dialog_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source ->
+ TraktDialogOption(
+ label = moreLikeThisSourceLabel(source),
+ selected = source == selectedSource,
+ onClick = { onSourceSelected(source) },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(Res.string.settings_playback_dialog_close),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun TraktDialogOption(
label: String,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt
index 0a29dd1b..dd690f74 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt
@@ -7,6 +7,7 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.MetaTrailer
import com.nuvio.app.features.details.MetaVideo
+import com.nuvio.app.features.details.MoreLikeThisSource
import com.nuvio.app.features.details.PersonDetail
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
@@ -618,6 +619,7 @@ object TmdbMetadataService {
country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "),
language = enrichment.language,
moreLikeThis = enrichment.moreLikeThis,
+ moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() },
collectionName = enrichment.collectionName,
collectionItems = enrichment.collectionItems,
trailers = enrichment.trailers,
@@ -726,7 +728,10 @@ object TmdbMetadataService {
}
if (enrichment != null && settings.useMoreLikeThis) {
- updated = updated.copy(moreLikeThis = enrichment.moreLikeThis)
+ updated = updated.copy(
+ moreLikeThis = enrichment.moreLikeThis,
+ moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() },
+ )
}
if (enrichment != null && settings.useCollections) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt
new file mode 100644
index 00000000..c0b61bef
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt
@@ -0,0 +1,327 @@
+package com.nuvio.app.features.trakt
+
+import co.touchlab.kermit.Logger
+import com.nuvio.app.features.addons.httpRequestRaw
+import com.nuvio.app.features.details.MetaDetails
+import com.nuvio.app.features.home.MetaPreview
+import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.tmdb.TmdbService
+import io.ktor.http.encodeURLParameter
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.math.roundToInt
+
+private const val BASE_URL = "https://api.trakt.tv"
+private const val RELATED_LIMIT = 20
+private const val RELATED_CACHE_TTL_MS = 10 * 60_000L
+
+object TraktRelatedRepository {
+ private val log = Logger.withTag("TraktRelated")
+ private val json = Json { ignoreUnknownKeys = true }
+ private val cacheMutex = Mutex()
+ private val cache = mutableMapOf()
+
+ suspend fun getRelated(
+ meta: MetaDetails,
+ fallbackItemId: String? = null,
+ fallbackItemType: String? = null,
+ forceRefresh: Boolean = false,
+ ): List {
+ val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
+ val target = resolveRelatedTarget(
+ meta = meta,
+ fallbackItemId = fallbackItemId,
+ fallbackItemType = fallbackItemType,
+ headers = headers,
+ ) ?: return emptyList()
+ val cacheKey = "${target.type.apiValue}|${target.pathId}"
+
+ if (forceRefresh) {
+ cacheMutex.withLock { cache.remove(cacheKey) }
+ }
+
+ if (!forceRefresh) {
+ cacheMutex.withLock {
+ cache[cacheKey]?.let { cached ->
+ if (TraktPlatformClock.nowEpochMs() - cached.updatedAtMs <= RELATED_CACHE_TTL_MS) {
+ return cached.items
+ }
+ }
+ }
+ }
+
+ val items = fetchRelated(target = target, headers = headers)
+ .distinctBy { it.stableRelatedKey() }
+ .take(RELATED_LIMIT)
+
+ cacheMutex.withLock {
+ cache[cacheKey] = TimedCache(
+ items = items,
+ updatedAtMs = TraktPlatformClock.nowEpochMs(),
+ )
+ }
+ return items
+ }
+
+ fun clearCache() {
+ cache.clear()
+ }
+
+ private suspend fun fetchRelated(
+ target: ResolvedRelatedTarget,
+ headers: Map,
+ ): List {
+ val endpoint = when (target.type) {
+ TraktRelatedType.MOVIE -> "movies"
+ TraktRelatedType.SHOW -> "shows"
+ }
+ val response = httpRequestRaw(
+ method = "GET",
+ url = buildTraktUrl("$endpoint/${target.pathId}/related", mapOf("extended" to "full,images")),
+ headers = jsonHeaders(headers),
+ body = "",
+ )
+
+ if (response.status == 404) return emptyList()
+ if (response.status !in 200..299) {
+ error("Failed to load Trakt related titles (${response.status})")
+ }
+
+ return when (target.type) {
+ TraktRelatedType.MOVIE -> json.decodeFromString>(response.body)
+ .mapNotNull { it.toMetaPreview() }
+ TraktRelatedType.SHOW -> json.decodeFromString>(response.body)
+ .mapNotNull { it.toMetaPreview() }
+ }
+ }
+
+ private suspend fun resolveRelatedTarget(
+ meta: MetaDetails,
+ fallbackItemId: String?,
+ fallbackItemType: String?,
+ headers: Map,
+ ): ResolvedRelatedTarget? {
+ val type = resolveRelatedType(meta = meta, fallbackItemType = fallbackItemType) ?: return null
+ resolveDirectPathId(meta.id)?.let { return ResolvedRelatedTarget(type, it) }
+ resolveDirectPathId(fallbackItemId)?.let { return ResolvedRelatedTarget(type, it) }
+
+ val tmdbId = resolveTmdbCandidate(meta.id)
+ ?: resolveTmdbCandidate(fallbackItemId)
+ ?: TmdbService.ensureTmdbId(meta.id, meta.type)?.toIntOrNull()
+ ?: fallbackItemId?.let { TmdbService.ensureTmdbId(it, fallbackItemType ?: meta.type) }?.toIntOrNull()
+ ?: return null
+
+ return resolveViaTraktSearch(type = type, tmdbId = tmdbId, headers = headers)
+ }
+
+ private fun resolveRelatedType(meta: MetaDetails, fallbackItemType: String?): TraktRelatedType? {
+ return when (normalizeRelatedType(meta.type)) {
+ TraktRelatedType.MOVIE -> TraktRelatedType.MOVIE
+ TraktRelatedType.SHOW -> TraktRelatedType.SHOW
+ null -> normalizeRelatedType(fallbackItemType)
+ }
+ }
+
+ private fun normalizeRelatedType(value: String?): TraktRelatedType? =
+ when (value?.trim()?.lowercase()) {
+ "movie", "film" -> TraktRelatedType.MOVIE
+ "series", "show", "tv", "tvshow" -> TraktRelatedType.SHOW
+ else -> null
+ }
+
+ private fun resolveDirectPathId(value: String?): String? {
+ val raw = value?.trim().orEmpty()
+ if (raw.isBlank()) return null
+ extractImdbId(raw)?.let { return it }
+ parseTraktContentIds(raw).trakt?.let { return it.toString() }
+ return raw
+ .takeIf { !it.startsWith("tmdb:", ignoreCase = true) }
+ ?.takeIf { it.all(Char::isDigit) }
+ }
+
+ private fun resolveTmdbCandidate(value: String?): Int? =
+ parseTraktContentIds(value).tmdb ?: extractTmdbId(value)
+
+ private suspend fun resolveViaTraktSearch(
+ type: TraktRelatedType,
+ tmdbId: Int,
+ headers: Map,
+ ): ResolvedRelatedTarget? {
+ val response = runCatching {
+ httpRequestRaw(
+ method = "GET",
+ url = buildTraktUrl(
+ endpoint = "search/tmdb/$tmdbId",
+ query = mapOf("type" to type.apiValue),
+ ),
+ headers = jsonHeaders(headers),
+ body = "",
+ )
+ }.onFailure { error ->
+ log.w(error) { "TMDB to Trakt lookup failed for tmdbId=$tmdbId" }
+ }.getOrNull() ?: return null
+
+ if (response.status == 404) return null
+ if (response.status !in 200..299) {
+ log.w { "Failed to resolve Trakt id for tmdbId=$tmdbId (${response.status})" }
+ return null
+ }
+
+ val results = runCatching {
+ json.decodeFromString>(response.body)
+ }.getOrDefault(emptyList())
+ val match = results.firstOrNull { it.type.equals(type.apiValue, ignoreCase = true) }
+ val ids = when (type) {
+ TraktRelatedType.MOVIE -> match?.movie?.ids
+ TraktRelatedType.SHOW -> match?.show?.ids
+ }
+ return ids?.bestPathId()?.let { ResolvedRelatedTarget(type, it) }
+ }
+
+ private fun buildTraktUrl(endpoint: String, query: Map = emptyMap()): String {
+ val queryString = (query + mapOf("page" to "1", "limit" to RELATED_LIMIT.toString()))
+ .entries
+ .filter { (_, value) -> value.isNotBlank() }
+ .joinToString("&") { (key, value) ->
+ "${key.encodeURLParameter()}=${value.encodeURLParameter()}"
+ }
+ return "$BASE_URL/${endpoint.trim('/')}" + if (queryString.isBlank()) "" else "?$queryString"
+ }
+
+ private fun jsonHeaders(headers: Map): Map =
+ mapOf("Accept" to "application/json") + headers
+}
+
+private data class TimedCache(
+ val items: List,
+ val updatedAtMs: Long,
+)
+
+private enum class TraktRelatedType(val apiValue: String) {
+ MOVIE("movie"),
+ SHOW("show"),
+}
+
+private data class ResolvedRelatedTarget(
+ val type: TraktRelatedType,
+ val pathId: String,
+)
+
+@Serializable
+private data class TraktRelatedSearchResultDto(
+ val type: String? = null,
+ val movie: TraktRelatedSearchItemDto? = null,
+ val show: TraktRelatedSearchItemDto? = null,
+)
+
+@Serializable
+private data class TraktRelatedSearchItemDto(
+ val ids: TraktExternalIds? = null,
+)
+
+@Serializable
+private data class TraktRelatedMovieDto(
+ val title: String? = null,
+ @SerialName("original_title") val originalTitle: String? = null,
+ val year: Int? = null,
+ val ids: TraktExternalIds? = null,
+ val overview: String? = null,
+ val released: String? = null,
+ val rating: Double? = null,
+ val genres: List? = null,
+ val images: TraktImagesDto? = null,
+)
+
+@Serializable
+private data class TraktRelatedShowDto(
+ val title: String? = null,
+ @SerialName("original_title") val originalTitle: String? = null,
+ val year: Int? = null,
+ val ids: TraktExternalIds? = null,
+ val overview: String? = null,
+ @SerialName("first_aired") val firstAired: String? = null,
+ val rating: Double? = null,
+ val genres: List? = null,
+ val images: TraktImagesDto? = null,
+)
+
+private fun TraktRelatedMovieDto.toMetaPreview(): MetaPreview? {
+ val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank)
+ ?: originalTitle?.trim()?.takeIf(String::isNotBlank)
+ ?: return null
+ val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "movie"))
+ if (contentId.isBlank()) return null
+
+ return MetaPreview(
+ id = contentId,
+ type = "movie",
+ name = normalizedTitle,
+ poster = images.traktBestPosterUrl(),
+ banner = images.traktBestBackdropUrl(),
+ logo = images.traktBestLogoUrl(),
+ posterShape = PosterShape.Poster,
+ description = overview?.trim()?.takeIf(String::isNotBlank),
+ releaseInfo = year?.toString() ?: released?.take(4),
+ rawReleaseDate = released,
+ imdbRating = rating?.formatTraktRating(),
+ genres = genres.orEmpty(),
+ )
+}
+
+private fun TraktRelatedShowDto.toMetaPreview(): MetaPreview? {
+ val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank)
+ ?: originalTitle?.trim()?.takeIf(String::isNotBlank)
+ ?: return null
+ val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "series"))
+ if (contentId.isBlank()) return null
+
+ return MetaPreview(
+ id = contentId,
+ type = "series",
+ name = normalizedTitle,
+ poster = images.traktBestPosterUrl(),
+ banner = images.traktBestBackdropUrl(),
+ logo = images.traktBestLogoUrl(),
+ posterShape = PosterShape.Poster,
+ description = overview?.trim()?.takeIf(String::isNotBlank),
+ releaseInfo = year?.toString() ?: firstAired?.take(4),
+ rawReleaseDate = firstAired,
+ imdbRating = rating?.formatTraktRating(),
+ genres = genres.orEmpty(),
+ )
+}
+
+private fun fallbackTraktContentId(ids: TraktExternalIds?, typePrefix: String): String? =
+ ids?.slug?.takeIf { it.isNotBlank() }?.let { "$typePrefix:$it" }
+ ?: ids?.trakt?.let { "trakt:$it" }
+
+private fun TraktExternalIds.bestPathId(): String? =
+ imdb?.takeIf { it.isNotBlank() }
+ ?: trakt?.toString()
+ ?: slug?.takeIf { it.isNotBlank() }
+
+private fun MetaPreview.stableRelatedKey(): String = "$type:$id"
+
+private fun extractImdbId(value: String?): String? =
+ value
+ ?.trim()
+ ?.split(':', '/', '?', '&')
+ ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
+ ?.takeIf { it.length > 2 }
+
+private fun extractTmdbId(value: String?): Int? {
+ val trimmed = value?.trim().orEmpty()
+ if (trimmed.isBlank()) return null
+ return trimmed
+ .takeIf { it.startsWith("tmdb:", ignoreCase = true) }
+ ?.substringAfter(':')
+ ?.substringBefore(':')
+ ?.substringBefore('/')
+ ?.toIntOrNull()
+}
+
+private fun Double.formatTraktRating(): String =
+ ((this * 10).roundToInt() / 10.0).toString()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
index ee9cccd4..3b22d9dc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
@@ -41,10 +41,24 @@ val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT
fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE
+@Serializable
+enum class MoreLikeThisSourcePreference {
+ TRAKT,
+ TMDB;
+
+ companion object {
+ fun fromStorage(value: String?): MoreLikeThisSourcePreference =
+ entries.firstOrNull { it.name == value } ?: DEFAULT_MORE_LIKE_THIS_SOURCE
+ }
+}
+
+val DEFAULT_MORE_LIKE_THIS_SOURCE: MoreLikeThisSourcePreference = MoreLikeThisSourcePreference.TRAKT
+
data class TraktSettingsUiState(
val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE,
+ val moreLikeThisSource: MoreLikeThisSourcePreference = DEFAULT_MORE_LIKE_THIS_SOURCE,
)
@Serializable
@@ -52,6 +66,7 @@ private data class StoredTraktSettings(
val watchProgressSource: String? = null,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
val librarySourceMode: String? = null,
+ val moreLikeThisSource: String? = null,
)
object TraktSettingsRepository {
@@ -101,6 +116,13 @@ object TraktSettingsRepository {
persist()
}
+ fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) {
+ ensureLoaded()
+ if (_uiState.value.moreLikeThisSource == source) return
+ _uiState.value = _uiState.value.copy(moreLikeThisSource = source)
+ persist()
+ }
+
private fun loadFromDisk() {
hasLoaded = true
@@ -119,6 +141,7 @@ object TraktSettingsRepository {
watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode),
+ moreLikeThisSource = MoreLikeThisSourcePreference.fromStorage(stored.moreLikeThisSource),
)
} else {
TraktSettingsUiState()
@@ -132,6 +155,7 @@ object TraktSettingsRepository {
watchProgressSource = _uiState.value.watchProgressSource.name,
continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
librarySourceMode = _uiState.value.librarySourceMode.name,
+ moreLikeThisSource = _uiState.value.moreLikeThisSource.name,
),
),
)
@@ -164,3 +188,8 @@ fun shouldUseTraktLibrary(
isAuthenticated: Boolean,
source: LibrarySourceMode,
): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT
+
+fun shouldUseTraktMoreLikeThis(
+ isAuthenticated: Boolean,
+ source: MoreLikeThisSourcePreference,
+): Boolean = isAuthenticated && source == MoreLikeThisSourcePreference.TRAKT
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
index f504fcc8..c051244a 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
@@ -34,6 +34,19 @@ class TraktSettingsRepositoryTest {
assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL"))
}
+ @Test
+ fun `more like this source defaults to Trakt for unset or invalid storage`() {
+ assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage(null))
+ assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage(""))
+ assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("not-a-source"))
+ }
+
+ @Test
+ fun `more like this source restores valid storage values`() {
+ assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("TRAKT"))
+ assertEquals(MoreLikeThisSourcePreference.TMDB, MoreLikeThisSourcePreference.fromStorage("TMDB"))
+ }
+
@Test
fun `continue watching cap normalizes finite windows and all history`() {
assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
@@ -64,4 +77,26 @@ class TraktSettingsRepositoryTest {
effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT),
)
}
+
+ @Test
+ fun `Trakt more like this is active only when authenticated and selected`() {
+ assertFalse(
+ shouldUseTraktMoreLikeThis(
+ isAuthenticated = false,
+ source = MoreLikeThisSourcePreference.TRAKT,
+ ),
+ )
+ assertFalse(
+ shouldUseTraktMoreLikeThis(
+ isAuthenticated = true,
+ source = MoreLikeThisSourcePreference.TMDB,
+ ),
+ )
+ assertTrue(
+ shouldUseTraktMoreLikeThis(
+ isAuthenticated = true,
+ source = MoreLikeThisSourcePreference.TRAKT,
+ ),
+ )
+ }
}
From daac198e1b72d51091527382a680fbf396a17ae2 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 7 Jun 2026 03:09:29 +0530
Subject: [PATCH 8/8] feat(badges): change pos - top,bottom
---
.../StreamBadgeSettingsStorage.android.kt | 11 ++-
.../composeResources/values/strings.xml | 6 ++
.../app/features/player/PlayerSourcesPanel.kt | 68 ++++++++++++----
.../app/features/settings/SettingsSearch.kt | 9 +++
.../features/settings/StreamsSettingsPage.kt | 77 +++++++++++++++++++
.../streams/StreamBadgeSettingsRepository.kt | 29 +++++++
.../streams/StreamBadgeSettingsStorage.kt | 2 +
.../app/features/streams/StreamsScreen.kt | 56 ++++++++++----
.../PlatformLocalAccountDataCleaner.ios.kt | 2 +
.../streams/StreamBadgeSettingsStorage.ios.kt | 11 ++-
10 files changed, 242 insertions(+), 29 deletions(-)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt
index 76c50809..cfab3bd0 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt
@@ -16,9 +16,10 @@ actual object StreamBadgeSettingsStorage {
private const val legacyDebridPreferencesName = "nuvio_debrid_settings"
private const val streamBadgeRulesKey = "stream_badge_rules"
private const val showFileSizeBadgesKey = "show_file_size_badges"
+ private const val streamBadgePlacementKey = "stream_badge_placement"
private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules"
- private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey)
+ private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey)
private var preferences: SharedPreferences? = null
private var legacyDebridPreferences: SharedPreferences? = null
@@ -40,6 +41,12 @@ actual object StreamBadgeSettingsStorage {
saveBoolean(showFileSizeBadgesKey, enabled)
}
+ actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey)
+
+ actual fun saveStreamBadgePlacement(placement: String) {
+ saveString(streamBadgePlacementKey, placement)
+ }
+
actual fun loadLegacyDebridStreamBadgeRules(): String? =
legacyDebridPreferences?.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey), null)
@@ -80,6 +87,7 @@ actual object StreamBadgeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) }
loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) }
+ loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@@ -89,5 +97,6 @@ actual object StreamBadgeSettingsStorage {
payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules)
payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges)
+ payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement)
}
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index c8fe87a7..582b500a 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -681,6 +681,12 @@
Fusion Style
Size badges
Show file size badges in stream results and player source panels.
+ Badge position
+ Choose whether Fusion and size badges appear above or below stream cards.
+ Badge position
+ Select where stream badges appear on stream cards.
+ Top
+ Bottom
Fusion badge URLs
Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately.
Manage imported Fusion-style stream badge JSON URLs.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt
index d62ad6e1..83ef6aa1 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt
@@ -15,6 +15,7 @@ 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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -50,7 +51,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.debrid.DebridSettingsRepository
+import com.nuvio.app.features.streams.StreamBadge
import com.nuvio.app.features.streams.StreamBadgeImage
+import com.nuvio.app.features.streams.StreamBadgePlacement
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamFileSizeBadge
import com.nuvio.app.features.streams.StreamItem
@@ -228,6 +231,7 @@ fun PlayerSourcesPanel(
isCurrent = isCurrent,
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
+ badgePlacement = streamBadgeSettings.badgePlacement,
onClick = { onStreamSelected(stream) },
)
}
@@ -247,10 +251,13 @@ private fun SourceStreamRow(
isCurrent: Boolean,
enabled: Boolean,
showFileSizeBadges: Boolean,
+ badgePlacement: StreamBadgePlacement,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val cardShape = RoundedCornerShape(12.dp)
+ val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
+ val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
Row(
modifier = Modifier
@@ -279,6 +286,15 @@ private fun SourceStreamRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
+ if (hasBadgeMetadata && badgePlacement == StreamBadgePlacement.TOP) {
+ SourceStreamBadgeRow(
+ badgeImages = badgeImages,
+ stream = stream,
+ showFileSizeBadges = showFileSizeBadges,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -325,22 +341,25 @@ private fun SourceStreamRow(
}
Spacer(modifier = Modifier.height(6.dp))
- val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
- val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
- Row(
- modifier = Modifier.horizontalScroll(rememberScrollState()),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- badgeImages.forEach { badge ->
- StreamBadgeImage(badge = badge)
- }
- if (showFileSizeBadges) {
- StreamFileSizeBadge(stream = stream)
+ if (badgePlacement == StreamBadgePlacement.BOTTOM) {
+ SourceStreamBadgeRow(
+ badgeImages = badgeImages,
+ stream = stream,
+ showFileSizeBadges = showFileSizeBadges,
+ ) {
+ Text(
+ text = stream.addonName,
+ modifier = if (hasBadgeMetadata) Modifier.padding(start = 4.dp) else Modifier,
+ color = colorScheme.onSurfaceVariant,
+ fontSize = 11.sp,
+ fontStyle = FontStyle.Italic,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
+ } else {
Text(
text = stream.addonName,
- modifier = if (hasBadgeMetadata) Modifier.padding(start = 4.dp) else Modifier,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
@@ -352,6 +371,29 @@ private fun SourceStreamRow(
}
}
+@Composable
+private fun SourceStreamBadgeRow(
+ badgeImages: List,
+ stream: StreamItem,
+ showFileSizeBadges: Boolean,
+ modifier: Modifier = Modifier,
+ trailingContent: @Composable RowScope.() -> Unit = {},
+) {
+ Row(
+ modifier = modifier.horizontalScroll(rememberScrollState()),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ badgeImages.forEach { badge ->
+ StreamBadgeImage(badge = badge)
+ }
+ if (showFileSizeBadges) {
+ StreamFileSizeBadge(stream = stream)
+ }
+ trailingContent()
+ }
+}
+
@Composable
internal fun AddonFilterChip(
label: String,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 796cca62..d19ccef2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -471,6 +471,15 @@ internal fun settingsSearchEntries(
section = stringResource(Res.string.settings_stream_badges_section),
icon = Icons.Rounded.Style,
)
+ addRow(
+ page = SettingsPage.Streams,
+ key = "stream-badge-position",
+ title = stringResource(Res.string.settings_stream_badge_position_title),
+ description = stringResource(Res.string.settings_stream_badge_position_description),
+ pageLabel = streamsPage,
+ section = stringResource(Res.string.settings_stream_badges_section),
+ icon = Icons.Rounded.Style,
+ )
addRow(
page = SettingsPage.Streams,
key = "stream-badge-urls",
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt
index e86c7ab7..bb39a750 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt
@@ -53,6 +53,7 @@ import com.nuvio.app.features.streams.StreamBadgeChipSize
import com.nuvio.app.features.streams.StreamBadgeFilter
import com.nuvio.app.features.streams.StreamBadgeImport
import com.nuvio.app.features.streams.StreamBadgeImportResult
+import com.nuvio.app.features.streams.StreamBadgePlacement
import com.nuvio.app.features.streams.StreamBadgeRules
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import kotlinx.coroutines.launch
@@ -74,6 +75,12 @@ import nuvio.composeapp.generated.resources.settings_fusion_badge_url_status_sum
import nuvio.composeapp.generated.resources.settings_fusion_badge_urls_imported
import nuvio.composeapp.generated.resources.settings_fusion_badges_empty
import nuvio.composeapp.generated.resources.settings_fusion_badges_summary
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_bottom
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_description
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_dialog_description
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_dialog_title
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_title
+import nuvio.composeapp.generated.resources.settings_stream_badge_position_top
import nuvio.composeapp.generated.resources.settings_stream_badge_urls_description
import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title
import nuvio.composeapp.generated.resources.settings_stream_badges_section
@@ -89,6 +96,8 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) {
}.collectAsStateWithLifecycle()
val currentRules = currentSettings.rules
var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) }
+ var showBadgePositionDialog by rememberSaveable { mutableStateOf(false) }
+ val badgePlacementLabel = streamBadgePlacementLabel(currentSettings.badgePlacement)
SettingsSection(
title = stringResource(Res.string.settings_stream_badges_section),
@@ -102,6 +111,13 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) {
isTablet = isTablet,
onCheckedChange = StreamBadgeSettingsRepository::setShowFileSizeBadges,
)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_stream_badge_position_title),
+ description = badgePlacementLabel,
+ icon = Icons.Rounded.Style,
+ isTablet = isTablet,
+ onClick = { showBadgePositionDialog = true },
+ )
SettingsNavigationRow(
title = stringResource(Res.string.settings_stream_badge_urls_title),
description = badgeRulesPreview(currentRules),
@@ -118,9 +134,27 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) {
onDismiss = { showBadgeImportDialog = false },
)
}
+
+ if (showBadgePositionDialog) {
+ StreamBadgePositionDialog(
+ selectedPlacement = currentSettings.badgePlacement,
+ onPlacementSelected = { placement ->
+ StreamBadgeSettingsRepository.setBadgePlacement(placement)
+ showBadgePositionDialog = false
+ },
+ onDismiss = { showBadgePositionDialog = false },
+ )
+ }
}
}
+@Composable
+private fun streamBadgePlacementLabel(placement: StreamBadgePlacement): String =
+ when (placement) {
+ StreamBadgePlacement.TOP -> stringResource(Res.string.settings_stream_badge_position_top)
+ StreamBadgePlacement.BOTTOM -> stringResource(Res.string.settings_stream_badge_position_bottom)
+ }
+
@Composable
private fun badgeRulesPreview(rules: StreamBadgeRules): String {
val normalizedRules = rules.normalized()
@@ -136,6 +170,49 @@ private fun badgeRulesPreview(rules: StreamBadgeRules): String {
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun StreamBadgePositionDialog(
+ selectedPlacement: StreamBadgePlacement,
+ onPlacementSelected: (StreamBadgePlacement) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ SettingsDialogSurface(title = stringResource(Res.string.settings_stream_badge_position_dialog_title)) {
+ Text(
+ text = stringResource(Res.string.settings_stream_badge_position_dialog_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ StreamBadgePlacement.entries.forEach { placement ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ RadioButton(
+ selected = placement == selectedPlacement,
+ onClick = { onPlacementSelected(placement) },
+ )
+ Text(
+ text = streamBadgePlacementLabel(placement),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(Res.string.action_cancel), maxLines = 1)
+ }
+ }
+ }
+ }
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun BadgeUrlManagerDialog(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt
index 6c34f6bd..c48690d4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt
@@ -15,8 +15,14 @@ import kotlinx.serialization.json.Json
data class StreamBadgeSettingsUiState(
val rules: StreamBadgeRules = StreamBadgeRules(),
val showFileSizeBadges: Boolean = true,
+ val badgePlacement: StreamBadgePlacement = StreamBadgePlacement.BOTTOM,
)
+enum class StreamBadgePlacement {
+ TOP,
+ BOTTOM,
+}
+
object StreamBadgeSettingsRepository {
private val _uiState = MutableStateFlow(StreamBadgeSettingsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -30,6 +36,7 @@ object StreamBadgeSettingsRepository {
private var hasLoaded = false
private var streamBadgeRules = StreamBadgeRules()
private var showFileSizeBadges = true
+ private var badgePlacement = StreamBadgePlacement.BOTTOM
fun ensureLoaded() {
if (hasLoaded) return
@@ -44,6 +51,7 @@ object StreamBadgeSettingsRepository {
hasLoaded = false
streamBadgeRules = StreamBadgeRules()
showFileSizeBadges = true
+ badgePlacement = StreamBadgePlacement.BOTTOM
_uiState.value = StreamBadgeSettingsUiState()
}
@@ -57,6 +65,11 @@ object StreamBadgeSettingsRepository {
return _uiState.value.showFileSizeBadges
}
+ fun badgePlacementSnapshot(): StreamBadgePlacement {
+ ensureLoaded()
+ return _uiState.value.badgePlacement
+ }
+
suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult {
ensureLoaded()
val normalizedUrl = url.trim()
@@ -120,6 +133,14 @@ object StreamBadgeSettingsRepository {
StreamBadgeSettingsStorage.saveShowFileSizeBadges(enabled)
}
+ fun setBadgePlacement(placement: StreamBadgePlacement) {
+ ensureLoaded()
+ if (badgePlacement == placement) return
+ badgePlacement = placement
+ publish()
+ StreamBadgeSettingsStorage.saveStreamBadgePlacement(placement.name)
+ }
+
private fun loadFromDisk() {
hasLoaded = true
val storedRules = parseStreamBadgeRules(StreamBadgeSettingsStorage.loadStreamBadgeRules())
@@ -130,6 +151,13 @@ object StreamBadgeSettingsRepository {
}
streamBadgeRules = storedRules ?: legacyRules ?: StreamBadgeRules()
showFileSizeBadges = StreamBadgeSettingsStorage.loadShowFileSizeBadges() ?: true
+ badgePlacement = StreamBadgeSettingsStorage.loadStreamBadgePlacement()
+ ?.let { storedPlacement ->
+ StreamBadgePlacement.entries.firstOrNull { placement ->
+ placement.name.equals(storedPlacement, ignoreCase = true)
+ }
+ }
+ ?: StreamBadgePlacement.BOTTOM
if (legacyRules != null) {
saveStreamBadgeRules()
StreamBadgeSettingsStorage.clearLegacyDebridStreamBadgeRules()
@@ -141,6 +169,7 @@ object StreamBadgeSettingsRepository {
_uiState.value = StreamBadgeSettingsUiState(
rules = streamBadgeRules,
showFileSizeBadges = showFileSizeBadges,
+ badgePlacement = badgePlacement,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt
index 0cfffc2f..5df55453 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt
@@ -7,6 +7,8 @@ internal expect object StreamBadgeSettingsStorage {
fun saveStreamBadgeRules(rules: String)
fun loadShowFileSizeBadges(): Boolean?
fun saveShowFileSizeBadges(enabled: Boolean)
+ fun loadStreamBadgePlacement(): String?
+ fun saveStreamBadgePlacement(placement: String)
fun loadLegacyDebridStreamBadgeRules(): String?
fun clearLegacyDebridStreamBadgeRules()
fun exportToSyncPayload(): JsonObject
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
index d956a0e6..0f6281e2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
@@ -833,6 +833,7 @@ internal fun StreamList(
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
+ badgePlacement = streamBadgeSettings.badgePlacement,
onStreamSelected = onStreamSelected,
onStreamLongPress = onStreamLongPress,
resumePositionMs = resumePositionMs,
@@ -859,6 +860,7 @@ private fun LazyListScope.streamSection(
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
showFileSizeBadges: Boolean,
+ badgePlacement: StreamBadgePlacement,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
onStreamLongPress: (StreamItem) -> Unit,
resumePositionMs: Long?,
@@ -905,6 +907,7 @@ private fun LazyListScope.streamSection(
enabled = stream.isSelectableForPlayback(debridEnabled),
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
showFileSizeBadges = showFileSizeBadges,
+ badgePlacement = badgePlacement,
onClick = {
if (stream.isSelectableForPlayback(debridEnabled)) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
@@ -1011,11 +1014,14 @@ private fun StreamCard(
enabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
showFileSizeBadges: Boolean,
+ badgePlacement: StreamBadgePlacement,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val cardShape = RoundedCornerShape(12.dp)
+ val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
+ val hasBadges = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
Row(
modifier = modifier
.fillMaxWidth()
@@ -1037,6 +1043,15 @@ private fun StreamCard(
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
+ if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) {
+ StreamCardBadgeRow(
+ badgeImages = badgeImages,
+ stream = stream,
+ showFileSizeBadges = showFileSizeBadges,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+
StreamNameWithInstantService(
stream = stream,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
@@ -1055,26 +1070,39 @@ private fun StreamCard(
)
}
- val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
- if (badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)) {
+ if (hasBadges && badgePlacement == StreamBadgePlacement.BOTTOM) {
Spacer(modifier = Modifier.height(5.dp))
- Row(
- modifier = Modifier.horizontalScroll(rememberScrollState()),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- badgeImages.forEach { badge ->
- StreamBadgeImage(badge = badge)
- }
- if (showFileSizeBadges) {
- StreamFileSizeBadge(stream = stream)
- }
- }
+ StreamCardBadgeRow(
+ badgeImages = badgeImages,
+ stream = stream,
+ showFileSizeBadges = showFileSizeBadges,
+ )
}
}
}
}
+@Composable
+private fun StreamCardBadgeRow(
+ badgeImages: List,
+ stream: StreamItem,
+ showFileSizeBadges: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.horizontalScroll(rememberScrollState()),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ badgeImages.forEach { badge ->
+ StreamBadgeImage(badge = badge)
+ }
+ if (showFileSizeBadges) {
+ StreamFileSizeBadge(stream = stream)
+ }
+ }
+}
+
@Composable
private fun StreamNameWithInstantService(
stream: StreamItem,
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index 288ffdf2..6c7c0792 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -35,6 +35,8 @@ internal actual object PlatformLocalAccountDataCleaner {
"stream_reuse_last_link_enabled",
"stream_reuse_last_link_cache_hours",
"stream_badge_rules",
+ "show_file_size_badges",
+ "stream_badge_placement",
"debrid_stream_badge_rules",
"p2p_enabled",
"enable_upload",
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt
index 743c25c6..716304d8 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt
@@ -13,8 +13,9 @@ import platform.Foundation.NSUserDefaults
actual object StreamBadgeSettingsStorage {
private const val streamBadgeRulesKey = "stream_badge_rules"
private const val showFileSizeBadgesKey = "show_file_size_badges"
+ private const val streamBadgePlacementKey = "stream_badge_placement"
private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules"
- private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey)
+ private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey)
actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey)
@@ -28,6 +29,12 @@ actual object StreamBadgeSettingsStorage {
saveBoolean(showFileSizeBadgesKey, enabled)
}
+ actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey)
+
+ actual fun saveStreamBadgePlacement(placement: String) {
+ saveString(streamBadgePlacementKey, placement)
+ }
+
actual fun loadLegacyDebridStreamBadgeRules(): String? =
loadString(legacyDebridStreamBadgeRulesKey)
@@ -59,6 +66,7 @@ actual object StreamBadgeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) }
loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) }
+ loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@@ -68,5 +76,6 @@ actual object StreamBadgeSettingsStorage {
payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules)
payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges)
+ payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement)
}
}