Merge remote-tracking branch 'upstream/cmp-rewrite' into cmp and resolve conflicts

This commit is contained in:
paregi12 2026-06-07 12:37:57 +05:30
commit 8b2df4b2d9
58 changed files with 5045 additions and 3166 deletions

2
MPVKit

@ -1 +1 @@
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5

View file

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

View file

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

View file

@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage {
?.putString(key, payload)
?.apply()
}
actual fun removePayload(key: String) {
preferences
?.edit()
?.remove(key)
?.apply()
}
}

View file

@ -404,6 +404,7 @@
<string name="compose_settings_category_general">General</string>
<string name="compose_settings_page_account">Account</string>
<string name="compose_settings_page_addons">Addons</string>
<string name="compose_settings_page_advanced">Advanced</string>
<string name="compose_settings_page_appearance">Layout</string>
<string name="compose_settings_page_content_discovery">Content &amp; Discovery</string>
<string name="compose_settings_page_continue_watching">Continue Watching</string>
@ -425,6 +426,8 @@
<string name="compose_settings_root_about_section">ABOUT</string>
<string name="compose_settings_root_account_description">Account and sync status</string>
<string name="compose_settings_root_account_section">ACCOUNT</string>
<string name="compose_settings_root_advanced_description">Startup and profile behavior.</string>
<string name="compose_settings_root_advanced_section">ADVANCED</string>
<string name="compose_settings_root_appearance_description">Home structure and poster styles</string>
<string name="compose_settings_root_check_updates_description">Download latest release</string>
<string name="compose_settings_root_check_updates_title">Check for updates</string>
@ -441,6 +444,13 @@
<string name="settings_search_empty">No settings found.</string>
<string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string>
<string name="settings_advanced_section_startup">STARTUP</string>
<string name="settings_advanced_section_cache">CACHE</string>
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached thumbnails, titles, and enrichment data for Continue Watching</string>
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
<string name="settings_licenses_attributions_section_data">DATA &amp; SERVICES</string>
<string name="settings_licenses_attributions_section_playback">PLAYBACK LICENSE</string>
@ -671,6 +681,12 @@
<string name="settings_stream_badges_section">Fusion Style</string>
<string name="settings_stream_size_badges_title">Size badges</string>
<string name="settings_stream_size_badges_description">Show file size badges in stream results and player source panels.</string>
<string name="settings_stream_badge_position_title">Badge position</string>
<string name="settings_stream_badge_position_description">Choose whether Fusion and size badges appear above or below stream cards.</string>
<string name="settings_stream_badge_position_dialog_title">Badge position</string>
<string name="settings_stream_badge_position_dialog_description">Select where stream badges appear on stream cards.</string>
<string name="settings_stream_badge_position_top">Top</string>
<string name="settings_stream_badge_position_bottom">Bottom</string>
<string name="settings_stream_badge_urls_title">Fusion badge URLs</string>
<string name="settings_stream_badge_urls_description">Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately.</string>
<string name="settings_stream_badge_urls_search_description">Manage imported Fusion-style stream badge JSON URLs.</string>
@ -998,6 +1014,12 @@
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
<string name="trakt_more_like_this_source_title">More Like This source</string>
<string name="trakt_more_like_this_source_subtitle">Choose where recommendations come from on detail pages</string>
<string name="trakt_more_like_this_source_dialog_title">More Like This source</string>
<string name="trakt_more_like_this_source_dialog_subtitle">Select the source for recommendations shown on detail pages.</string>
<string name="trakt_more_like_this_source_trakt">Trakt</string>
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
<string name="trakt_continue_watching_window">Continue Watching Window</string>
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
<string name="trakt_cw_window_title">Continue Watching Window</string>
@ -1123,6 +1145,8 @@
<string name="details_director">Director</string>
<string name="details_failed_to_load">Failed to load</string>
<string name="details_more_like_this">More Like This</string>
<string name="detail_more_like_this_powered_by_tmdb">Powered by TMDB</string>
<string name="detail_more_like_this_powered_by_trakt">Powered by Trakt</string>
<string name="details_seasons">Seasons</string>
<string name="details_series_missing_numbers">This addon returned videos for the series, but none included season or episode numbers.</string>
<string name="details_series_no_metadata">This addon did not provide episode metadata for this series.</string>
@ -1560,6 +1584,12 @@
<string name="settings_playback_ios_display_color_hint_desc">Let mpv target the active display color space by default.</string>
<string name="settings_playback_ios_target_primaries">Target primaries</string>
<string name="settings_playback_ios_target_transfer">Target transfer</string>
<!-- Playback settings iOS audio output -->
<string name="settings_playback_ios_audio_output_section">iOS audio output</string>
<string name="settings_playback_ios_audio_output">Audio output</string>
<string name="settings_playback_ios_audio_output_auto_desc">Try AVFoundation first, then fall back to AudioUnit.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Experimental support for Spatial Audio and multichannel output.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Use the legacy AudioUnit output.</string>
<!-- Debrid Result Management section -->
<string name="settings_debrid_section_result_management">Result Management</string>
<string name="settings_debrid_max_results">Max results</string>
@ -1660,6 +1690,7 @@
<string name="submit_intro_capture">Capture</string>
<!-- iOS advanced playback -->
<string name="settings_playback_ios_hw_decoder_dialog">Hardware decoder</string>
<string name="settings_playback_ios_audio_output_dialog">Audio output</string>
<string name="settings_playback_ios_target_primaries_dialog">Target primaries</string>
<string name="settings_playback_ios_target_transfer_dialog">Target transfer</string>
<!-- Collection editor -->

View file

@ -413,6 +413,20 @@ fun App() {
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
fun rememberedStartupProfile(profiles: List<NuvioProfile>): 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<NuvioProfile>, 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

View file

@ -31,6 +31,7 @@ data class MetaDetails(
val website: String? = null,
val hasScheduledVideos: Boolean = false,
val moreLikeThis: List<MetaPreview> = emptyList(),
val moreLikeThisSource: MoreLikeThisSource? = null,
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val trailers: List<MetaTrailer> = emptyList(),
@ -38,6 +39,11 @@ data class MetaDetails(
val videos: List<MetaVideo> = emptyList(),
)
enum class MoreLikeThisSource {
TMDB,
TRAKT,
}
data class MetaExternalRating(
val source: String,
val value: Double,

View file

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

View file

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

View file

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

View file

@ -147,6 +147,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)

View file

@ -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<MetaVideo>,
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<Unit>()
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>): 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>): 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)
}
}
}

View file

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

View file

@ -0,0 +1,39 @@
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<String, String>,
val sourceResponseHeaders: Map<String, String>,
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 externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val torrentInfoHash: String?,
val torrentFileIdx: Int?,
val torrentFilename: String?,
val torrentMagnetUri: String?,
val torrentTrackers: List<String>,
val initialPositionMs: Long,
val initialProgressFraction: Float?,
)

View file

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

View file

@ -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<AudioTrack>,
selectedAudioIndex: Int,
onAudioTrackSelected: (Int) -> Unit,
onAudioModalDismissed: () -> Unit,
showSubtitleModal: Boolean,
activeSubtitleTab: SubtitleTab,
subtitleTracks: List<SubtitleTrack>,
selectedSubtitleIndex: Int,
addonSubtitles: List<AddonSubtitle>,
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<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
activeSeasonNumber: Int?,
activeEpisodeNumber: Int?,
watchProgressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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
}

View file

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

View file

@ -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<Boolean>,
val playerControlsLocked: State<Boolean>,
val currentPositionMs: State<Long>,
val currentDurationMs: State<Long>,
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()
},
)
}

View file

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

View file

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

View file

@ -0,0 +1,193 @@
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<String, String> get() = args.sourceHeaders
val sourceResponseHeaders: Map<String, String> 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<String> get() = args.torrentTrackers
val initialPositionMs: Long get() = args.initialPositionMs
val initialProgressFraction: Float? get() = args.initialProgressFraction
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> get() = args.externalSubtitles
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<AddonSubtitle> = 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<String?>(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<PlayerEngineController?>(null)
var playerControllerSourceUrl by mutableStateOf<String?>(null)
var errorMessage by mutableStateOf<String?>(null)
var isScrubbingTimeline by mutableStateOf(false)
var scrubbingPositionMs by mutableStateOf<Long?>(null)
var pausedOverlayVisible by mutableStateOf(false)
var gestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var liveGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var renderedGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var lockedOverlayVisible by mutableStateOf(false)
var gestureMessageJob by mutableStateOf<Job?>(null)
var accumulatedSeekResetJob by mutableStateOf<Job?>(null)
var seekProgressSyncJob by mutableStateOf<Job?>(null)
var accumulatedSeekState by mutableStateOf<PlayerAccumulatedSeekState?>(null)
var initialLoadCompleted by mutableStateOf(false)
var speedBoostRestoreSpeed by mutableStateOf<Float?>(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<TraktScrobbleItem?>(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<List<MetaVideo>>(emptyList())
var skipIntervals by mutableStateOf<List<SkipInterval>>(emptyList())
var activeSkipInterval by mutableStateOf<SkipInterval?>(null)
var skipIntervalDismissed by mutableStateOf(false)
var parentalWarnings by mutableStateOf<List<ParentalWarning>>(emptyList())
var showParentalGuide by mutableStateOf(false)
var parentalGuideHasShown by mutableStateOf(false)
var playbackStartedForParentalGuide by mutableStateOf(false)
var nextEpisodeInfo by mutableStateOf<NextEpisodeInfo?>(null)
var showNextEpisodeCard by mutableStateOf(false)
var nextEpisodeAutoPlaySearching by mutableStateOf(false)
var nextEpisodeAutoPlaySourceName by mutableStateOf<String?>(null)
var nextEpisodeAutoPlayCountdown by mutableStateOf<Int?>(null)
var nextEpisodeAutoPlayJob by mutableStateOf<Job?>(null)
var pendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
var showAudioModal by mutableStateOf(false)
var showSubtitleModal by mutableStateOf(false)
var showVideoSettingsModal by mutableStateOf(false)
var audioTracks by mutableStateOf<List<AudioTrack>>(emptyList())
var subtitleTracks by mutableStateOf<List<SubtitleTrack>>(emptyList())
var selectedAudioIndex by mutableStateOf(-1)
var selectedSubtitleIndex by mutableStateOf(-1)
var selectedAddonSubtitleId by mutableStateOf<String?>(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<String?>(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
}

View file

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

View file

@ -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<AddonSubtitle>
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
}
}
}

View file

@ -0,0 +1,524 @@
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,
externalSubtitles = externalSubtitles,
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
},
)
}

View file

@ -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,
)

View file

@ -73,7 +73,8 @@ 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 iosAudioOutputMode: IosAudioOutputMode = IosAudioOutputMode.Auto,
val iosExtendedDynamicRangeEnabled: Boolean = true,
val iosTargetColorspaceHintEnabled: Boolean = true,
val iosHdrComputePeakEnabled: Boolean = true,
@ -131,7 +132,8 @@ 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 iosAudioOutputMode = IosAudioOutputMode.Auto
private var iosExtendedDynamicRangeEnabled = true
private var iosTargetColorspaceHintEnabled = true
private var iosHdrComputePeakEnabled = true
@ -194,7 +196,8 @@ object PlayerSettingsRepository {
iosToneMappingMode = IosToneMappingMode.Auto
iosTargetPrimaries = IosTargetPrimaries.Auto
iosTargetTransfer = IosTargetTransfer.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
iosAudioOutputMode = IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = true
iosTargetColorspaceHintEnabled = true
iosHdrComputePeakEnabled = true
@ -317,7 +320,10 @@ object PlayerSettingsRepository {
?: IosTargetTransfer.Auto
iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode()
?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() }
?: IosHardwareDecoderMode.Auto
?: 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,

View file

@ -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?

View file

@ -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<StreamBadge>,
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,

View file

@ -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<Boolean>,
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<Boolean>,
isHoldToSpeedGestureActiveState: State<Boolean>,
currentPositionMsState: State<Long>,
currentDurationMsState: State<Long>,
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()
}
}
}

View file

@ -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<ManagedAddon>,
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 <T> findPreferredTrackIndex(
tracks: List<T>,
targets: List<String>,
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<SubtitleTrack>,
targets: List<String>,
): 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<AddonSubtitle>,
settings: PlayerSettingsUiState,
selectedAddonSubtitleId: String?,
): List<AddonSubtitle> {
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<String> {
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<AudioTrack>,
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<SubtitleTrack>,
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
}

View file

@ -43,6 +43,8 @@ data class ProfileState(
val profiles: List<NuvioProfile> = emptyList(),
val activeProfile: NuvioProfile? = null,
val isLoaded: Boolean = false,
val hasEverSelectedProfile: Boolean = false,
val rememberLastProfileEnabled: Boolean = false,
)
@Serializable

View file

@ -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<NuvioProfile> = 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,
),
),
)

View file

@ -0,0 +1,65 @@
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
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,
)
}
}
}
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
}
},
)
}
}
}
}

View file

@ -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 <T> IosEnumSelectionDialog(
options: List<T>,
selected: T,
label: (T) -> String,
description: @Composable (T) -> String? = { null },
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
@ -1918,6 +1956,7 @@ private fun <T> 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 <T> 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,

View file

@ -5,12 +5,14 @@ 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
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
@ -39,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(
@ -81,6 +84,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Root,
),
Advanced(
titleRes = Res.string.compose_settings_page_advanced,
category = SettingsCategory.Advanced,
parentPage = Root,
),
Notifications(
titleRes = Res.string.compose_settings_page_notifications,
category = SettingsCategory.General,

View file

@ -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,8 @@ 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_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
@ -60,6 +64,7 @@ internal fun LazyListScope.settingsRootContent(
onPlaybackClick: () -> Unit,
onStreamsClick: () -> Unit,
onAppearanceClick: () -> Unit,
onAdvancedClick: () -> Unit,
onNotificationsClick: () -> Unit,
onContentDiscoveryClick: () -> Unit,
onIntegrationsClick: () -> Unit,
@ -73,6 +78,7 @@ internal fun LazyListScope.settingsRootContent(
showAccountSection: Boolean = true,
showGeneralSection: Boolean = true,
showAboutSection: Boolean = true,
showAdvancedSection: Boolean = true,
) {
if (showAccountSection) {
item {
@ -212,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

View file

@ -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) },
@ -882,6 +896,7 @@ private fun TabletSettingsScreen(
showAccountSection = activeCategory == SettingsCategory.Account,
showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About,
showAdvancedSection = activeCategory == SettingsCategory.Advanced,
)
}
}
@ -928,6 +943,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,

View file

@ -85,10 +85,12 @@ 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)
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 +209,14 @@ 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),
category = advancedCategory,
icon = Icons.Rounded.Tune,
)
addPage(
page = SettingsPage.ContentDiscovery,
key = "content-discovery",
@ -368,6 +378,26 @@ 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),
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",
@ -441,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",
@ -779,6 +818,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,

View file

@ -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(

View file

@ -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<String?>(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,

View file

@ -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<StreamBadgeSettingsUiState> = _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,
)
}

View file

@ -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

View file

@ -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<StreamBadge>,
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,

View file

@ -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) {

View file

@ -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<String, TimedCache>()
suspend fun getRelated(
meta: MetaDetails,
fallbackItemId: String? = null,
fallbackItemType: String? = null,
forceRefresh: Boolean = false,
): List<MetaPreview> {
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<String, String>,
): List<MetaPreview> {
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<List<TraktRelatedMovieDto>>(response.body)
.mapNotNull { it.toMetaPreview() }
TraktRelatedType.SHOW -> json.decodeFromString<List<TraktRelatedShowDto>>(response.body)
.mapNotNull { it.toMetaPreview() }
}
}
private suspend fun resolveRelatedTarget(
meta: MetaDetails,
fallbackItemId: String?,
fallbackItemType: String?,
headers: Map<String, String>,
): 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<String, String>,
): 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<List<TraktRelatedSearchResultDto>>(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<String, String> = 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<String, String>): Map<String, String> =
mapOf("Accept" to "application/json") + headers
}
private data class TimedCache(
val items: List<MetaPreview>,
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<String>? = 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<String>? = 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()

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -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",

View file

@ -35,6 +35,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

View file

@ -311,6 +311,7 @@ actual fun PlatformPlayerSurface(
}
private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) {
configureAudioOutput(audioOutput = settings.iosAudioOutputMode.mpvValue)
configureVideoOutput(
hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue,
targetColorspaceHint = settings.iosTargetColorspaceHintEnabled,

View file

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

View file

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

View file

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

View file

@ -80,6 +80,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)) }
@ -221,6 +224,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
@ -347,7 +352,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"))
@ -534,7 +540,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")
@ -548,6 +554,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)
@ -909,6 +920,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 {
@ -999,6 +1011,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)))")

1
vendor/TorrServer vendored Submodule

@ -0,0 +1 @@
Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a