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