mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 09:09:29 +00:00
Merge remote-tracking branch 'upstream/cmp-rewrite' into cmp and resolve conflicts
This commit is contained in:
commit
8b2df4b2d9
58 changed files with 5045 additions and 3166 deletions
2
MPVKit
2
MPVKit
|
|
@ -1 +1 @@
|
|||
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
|
||||
Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage {
|
|||
?.putString(key, payload)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun removePayload(key: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(key)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
vendor/TorrServer
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a
|
||||
Loading…
Reference in a new issue