From 074fdafe6eac98bb401f22c1aeef093690cbde40 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:30:25 +0530 Subject: [PATCH 1/4] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index af51dfa7..ab76ebc7 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=75 -MARKETING_VERSION=0.2.4 +CURRENT_PROJECT_VERSION=76 +MARKETING_VERSION=0.2.5 From 850988bf87f0413634472e2788d45cf0e8df938c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:00:51 +0530 Subject: [PATCH 2/4] Update player controls state --- .../features/player/PlayerScreenRuntimeUi.kt | 471 +++++++++--------- iosApp/Configuration/Version.xcconfig | 2 +- 2 files changed, 249 insertions(+), 224 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index f28ca333..0d28e6df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -35,6 +35,11 @@ import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource +private val EmptyPlayerControlsState = PlayerControlsState() +private val IgnorePlayerControlsAction: (PlayerControlsAction) -> Boolean = { false } +private val IgnorePlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false } +private val IgnorePlayerControlsScrub: (Long) -> Boolean = { false } + @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this @@ -106,225 +111,229 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val openingOverlayWanted = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null - val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { - stringResource( - Res.string.compose_player_episode_title_format, - seasonNumber, - episodeNumber, - episodeTitle.orEmpty(), - ) - } else { - "" - } - val allFilterLabel = stringResource(Res.string.collections_tab_all) - val playingLabel = stringResource(Res.string.compose_player_playing) - val sourceFilters = buildPlayerControlFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val sourceItems = buildPlayerControlSourceItems() - val episodeItems = buildPlayerControlEpisodeItems() - val episodeSeasons = buildPlayerControlSeasonItems(episodeItems) - val episodeStreamFilters = buildPlayerControlEpisodeStreamFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val episodeStreamItems = buildPlayerControlEpisodeStreamItems() - val playerControlAddonSubtitles = buildPlayerControlAddonSubtitleItems() - val playerControlAutoSyncCues = buildPlayerControlSubtitleCueItems() - val themeColors = MaterialTheme.nuvio.colors - val selectedEpisodeLabel = episodeStreamsPanelState.selectedEpisode?.let { selected -> - val selectedCode = selected.playerControlsEpisodeCode() - buildString { - append(selectedCode) - if (selected.title.isNotBlank()) { - if (isNotEmpty()) append(" • ") - append(selected.title) - } - } - }.orEmpty() - val nativeSkipInterval = activeSkipInterval.takeIf { initialLoadCompleted && !pausedOverlayVisible } - val nextEpisodeForControls = nextEpisodeInfo.takeIf { isSeries && showNextEpisodeCard } - val nextEpisodeStatus = when { - nextEpisodeForControls == null -> "" - !nextEpisodeForControls.hasAired && !nextEpisodeForControls.unairedMessage.isNullOrBlank() -> - nextEpisodeForControls.unairedMessage.orEmpty() - nextEpisodeAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) - !nextEpisodeAutoPlaySourceName.isNullOrBlank() && nextEpisodeAutoPlayCountdown != null -> - stringResource( - Res.string.player_next_episode_playing_via_countdown, - nextEpisodeAutoPlaySourceName.orEmpty(), - nextEpisodeAutoPlayCountdown ?: 0, - ) - else -> "" - } - val playerControlsState = PlayerControlsState( - title = title, - episodeText = episodeText, - streamTitle = activeStreamTitle, - providerName = activeProviderName, - pauseOverlayWatchingLabel = stringResource(Res.string.compose_player_youre_watching), - pauseOverlayLogo = logo, - pauseOverlayEpisodeInfo = if (seasonNumber != null && episodeNumber != null) { - stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) - } else { - activeProviderName - }, - pauseOverlayEpisodeTitle = activeEpisodeTitle.orEmpty(), - pauseOverlayDescription = (pauseDescription ?: activeStreamSubtitle).orEmpty(), - resizeModeLabel = stringResource(resizeMode.labelRes), - playbackSpeedLabel = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), - subtitlesLabel = stringResource(Res.string.compose_player_subs), - audioLabel = stringResource(Res.string.compose_player_audio), - sourcesLabel = stringResource(Res.string.compose_player_sources), - episodesLabel = stringResource(Res.string.compose_player_episodes), - externalPlayerLabel = stringResource(Res.string.streams_open_external_player), - playLabel = stringResource(Res.string.detail_btn_play), - pauseLabel = stringResource(Res.string.compose_action_pause), - closeLabel = stringResource(Res.string.compose_player_close), - lockLabel = stringResource(Res.string.compose_player_lock_controls), - unlockLabel = stringResource(Res.string.compose_player_unlock_controls), - submitIntroLabel = stringResource(Res.string.submit_intro_action), - videoSettingsLabel = stringResource(Res.string.player_action_video_settings), - tapToUnlockLabel = stringResource(Res.string.compose_player_tap_to_unlock), - playbackErrorTitle = stringResource(Res.string.compose_player_playback_error), - playbackErrorMessage = errorMessage.orEmpty(), - playbackErrorActionLabel = stringResource(Res.string.compose_player_go_back), - sourcesPanelTitle = stringResource(Res.string.compose_player_panel_sources), - episodesPanelTitle = stringResource(Res.string.compose_player_panel_episodes), - streamsPanelTitle = stringResource(Res.string.compose_player_panel_streams), - allFilterLabel = allFilterLabel, - reloadLabel = stringResource(Res.string.compose_action_reload), - backLabel = stringResource(Res.string.action_back), - panelCloseLabel = stringResource(Res.string.action_close), - cancelLabel = stringResource(Res.string.action_cancel), - playingLabel = playingLabel, - noStreamsLabel = stringResource(Res.string.compose_player_no_streams_found), - noEpisodesLabel = stringResource(Res.string.compose_player_no_episodes_available), - submitIntroPanelTitle = stringResource(Res.string.submit_intro_title), - submitIntroSegmentTypeLabel = stringResource(Res.string.submit_intro_segment_type_label), - submitIntroSegmentIntroLabel = stringResource(Res.string.submit_intro_segment_intro), - submitIntroSegmentRecapLabel = stringResource(Res.string.submit_intro_segment_recap), - submitIntroSegmentOutroLabel = stringResource(Res.string.submit_intro_segment_outro), - submitIntroStartTimeLabel = stringResource(Res.string.submit_intro_start_time_label), - submitIntroEndTimeLabel = stringResource(Res.string.submit_intro_end_time_label), - submitIntroCaptureLabel = stringResource(Res.string.submit_intro_capture_button), - submitIntroSubmitLabel = stringResource(Res.string.submit_intro_button_submit), - p2pConsentTitle = stringResource(Res.string.p2p_consent_title), - p2pConsentBody = stringResource(Res.string.p2p_consent_body), - p2pConsentEnableLabel = stringResource(Res.string.p2p_consent_enable), - p2pConsentCancelLabel = stringResource(Res.string.p2p_consent_cancel), - subtitlesPanelTitle = stringResource(Res.string.compose_player_subtitles), - subtitleBuiltInTabLabel = stringResource(Res.string.compose_player_built_in), - subtitleAddonsTabLabel = stringResource(Res.string.addon_title), - subtitleStyleTabLabel = stringResource(Res.string.compose_player_style), - noneLabel = stringResource(Res.string.compose_player_none), - fetchSubtitlesLabel = stringResource(Res.string.compose_player_fetch_subtitles), - subtitleDelayLabel = stringResource(Res.string.compose_player_subtitle_delay), - resetLabel = stringResource(Res.string.compose_player_reset), - autoSyncLabel = stringResource(Res.string.compose_player_auto_sync), - reloadSmallLabel = stringResource(Res.string.compose_player_reload), - captureLineLabel = stringResource(Res.string.compose_player_capture_line), - selectAddonSubtitleFirstLabel = stringResource(Res.string.compose_player_select_addon_subtitle_first), - loadingSubtitleLinesLabel = stringResource(Res.string.compose_player_loading_lines), - fontSizeLabel = stringResource(Res.string.compose_player_font_size), - outlineLabel = stringResource(Res.string.compose_player_outline), - boldLabel = stringResource(Res.string.compose_player_bold), - bottomOffsetLabel = stringResource(Res.string.compose_player_bottom_offset), - colorLabel = stringResource(Res.string.compose_player_color), - textOpacityLabel = stringResource(Res.string.compose_player_text_opacity), - outlineColorLabel = stringResource(Res.string.compose_player_outline_color), - resetDefaultsLabel = stringResource(Res.string.compose_player_reset_defaults), - onLabel = stringResource(Res.string.compose_action_on), - offLabel = stringResource(Res.string.compose_action_off), - themeAccentColor = themeColors.accent.toCssColorString(), - themeAccentStrongColor = themeColors.accentStrong.toCssColorString(), - themeOnAccentColor = themeColors.onAccent.toCssColorString(), - themeFocusColor = themeColors.focusRing.toCssColorString(), - themeSelectedSurfaceColor = themeColors.accent.copy(alpha = 0.24f).toCssColorString(), - themeSelectedSurfaceHoverColor = themeColors.accent.copy(alpha = 0.34f).toCssColorString(), - themeSelectedRingColor = themeColors.accent.copy(alpha = 0.35f).toCssColorString(), - themeTimelineFillColor = themeColors.playerTimelineFill.toCssColorString(), - themeTimelineTrackColor = themeColors.playerTimelineTrack.toCssColorString(), - themeBufferingColor = themeColors.playerBuffering.toCssColorString(), - themeBufferingTrackColor = themeColors.playerBuffering.copy(alpha = 0.28f).toCssColorString(), - themeControlForegroundColor = themeColors.playerControlsForeground.toCssColorString(), - isPlaying = playbackSnapshot.isPlaying, - isLoading = playbackSnapshot.isLoading, - isLocked = playerControlsLocked, - lockedOverlayVisible = lockedOverlayVisible, - controlsVisible = controlsVisible && !playerControlsLocked, - parentalWarnings = parentalWarnings, - showParentalGuide = showParentalGuide, - showSubmitIntro = isSeries && - playerSettingsUiState.introSubmitEnabled && - playerSettingsUiState.introDbApiKey.isNotBlank() && - !activeSubmitIntroImdbId().isNullOrBlank(), - showVideoSettings = isIos, - showSources = activeVideoId != null, - showEpisodes = isSeries, - showExternalPlayer = args.onOpenInExternalPlayer != null, - durationMs = playbackSnapshot.durationMs, - positionMs = displayedPositionMs, - sourceIsLoading = sourceStreamsState.isAnyLoading, - sourceFilters = sourceFilters, - sourceItems = sourceItems, - episodeItems = episodeItems, - episodeSeasons = episodeSeasons, - episodeStreamsVisible = episodeStreamsPanelState.showStreams, - episodeStreamsIsLoading = episodeStreamsRepoState.isAnyLoading, - selectedEpisodeLabel = selectedEpisodeLabel, - episodeStreamFilters = episodeStreamFilters, - episodeStreamItems = episodeStreamItems, - submitIntroSegmentType = submitIntroSegmentType, - submitIntroStartTime = submitIntroStartTimeStr, - submitIntroEndTime = submitIntroEndTimeStr, - isSubmitIntroSubmitting = isSubmitIntroSubmitting, - submitIntroStatusMessage = submitIntroStatusMessage.orEmpty(), - showP2pConsent = playerControlsPendingP2pSwitch != null, - subtitleActiveTab = activeSubtitleTab.name, - addonSubtitleItems = playerControlAddonSubtitles, - isLoadingAddonSubtitles = isLoadingAddonSubtitles, - selectedAddonSubtitleId = selectedAddonSubtitleId.orEmpty(), - useCustomSubtitles = useCustomSubtitles, - subtitleStyle = subtitleStyle, - subtitleDelayMs = subtitleDelayMs, - hasSelectedAddonSubtitle = selectedAddonSubtitle != null, - subtitleAutoSyncCapturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: -1L, - subtitleAutoSyncCues = playerControlAutoSyncCues, - subtitleAutoSyncIsLoading = subtitleAutoSyncState.isLoading, - subtitleAutoSyncErrorMessage = subtitleAutoSyncState.errorMessage.orEmpty(), - closeModalsToken = playerControlsCloseModalsToken, - showOpeningOverlay = openingOverlayWanted, - openingArtwork = background ?: poster, - openingLogo = logo, - openingTitle = title, - openingMessage = p2pInitialLoadingMessage, - openingProgress = p2pInitialLoadingProgress, - skipPromptVisible = nativeSkipInterval != null && !playerControlsLocked, - skipPromptLabel = skipPromptLabel(nativeSkipInterval?.type), - skipPromptStartMs = ((nativeSkipInterval?.startTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptEndMs = ((nativeSkipInterval?.endTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptDismissed = skipIntervalDismissed, - nextEpisodeVisible = nextEpisodeForControls != null && !playerControlsLocked, - nextEpisodeHeaderLabel = stringResource(Res.string.player_next_episode), - nextEpisodeTitle = nextEpisodeForControls?.let { + val playerControlsState = if (isDesktop) { + val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { stringResource( Res.string.compose_player_episode_title_format, - it.season, - it.episode, - it.title, + seasonNumber, + episodeNumber, + episodeTitle.orEmpty(), ) - }.orEmpty(), - nextEpisodeThumbnail = nextEpisodeForControls?.thumbnail.orEmpty(), - nextEpisodeStatus = nextEpisodeStatus, - nextEpisodeActionLabel = if (nextEpisodeForControls?.hasAired == true) { - stringResource(Res.string.detail_btn_play) } else { - stringResource(Res.string.player_next_episode_unaired) - }, - nextEpisodePlayable = nextEpisodeForControls?.hasAired == true, - ) + "" + } + val allFilterLabel = stringResource(Res.string.collections_tab_all) + val playingLabel = stringResource(Res.string.compose_player_playing) + val sourceFilters = buildPlayerControlFilters( + allLabel = allFilterLabel, + selectedFilter = null, + ) + val sourceItems = buildPlayerControlSourceItems() + val episodeItems = buildPlayerControlEpisodeItems() + val episodeSeasons = buildPlayerControlSeasonItems(episodeItems) + val episodeStreamFilters = buildPlayerControlEpisodeStreamFilters( + allLabel = allFilterLabel, + selectedFilter = null, + ) + val episodeStreamItems = buildPlayerControlEpisodeStreamItems() + val playerControlAddonSubtitles = buildPlayerControlAddonSubtitleItems() + val playerControlAutoSyncCues = buildPlayerControlSubtitleCueItems() + val themeColors = MaterialTheme.nuvio.colors + val selectedEpisodeLabel = episodeStreamsPanelState.selectedEpisode?.let { selected -> + val selectedCode = selected.playerControlsEpisodeCode() + buildString { + append(selectedCode) + if (selected.title.isNotBlank()) { + if (isNotEmpty()) append(" • ") + append(selected.title) + } + } + }.orEmpty() + val nativeSkipInterval = activeSkipInterval.takeIf { initialLoadCompleted && !pausedOverlayVisible } + val nextEpisodeForControls = nextEpisodeInfo.takeIf { isSeries && showNextEpisodeCard } + val nextEpisodeStatus = when { + nextEpisodeForControls == null -> "" + !nextEpisodeForControls.hasAired && !nextEpisodeForControls.unairedMessage.isNullOrBlank() -> + nextEpisodeForControls.unairedMessage.orEmpty() + nextEpisodeAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) + !nextEpisodeAutoPlaySourceName.isNullOrBlank() && nextEpisodeAutoPlayCountdown != null -> + stringResource( + Res.string.player_next_episode_playing_via_countdown, + nextEpisodeAutoPlaySourceName.orEmpty(), + nextEpisodeAutoPlayCountdown ?: 0, + ) + else -> "" + } + PlayerControlsState( + title = title, + episodeText = episodeText, + streamTitle = activeStreamTitle, + providerName = activeProviderName, + pauseOverlayWatchingLabel = stringResource(Res.string.compose_player_youre_watching), + pauseOverlayLogo = logo, + pauseOverlayEpisodeInfo = if (seasonNumber != null && episodeNumber != null) { + stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + } else { + activeProviderName + }, + pauseOverlayEpisodeTitle = activeEpisodeTitle.orEmpty(), + pauseOverlayDescription = (pauseDescription ?: activeStreamSubtitle).orEmpty(), + resizeModeLabel = stringResource(resizeMode.labelRes), + playbackSpeedLabel = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), + subtitlesLabel = stringResource(Res.string.compose_player_subs), + audioLabel = stringResource(Res.string.compose_player_audio), + sourcesLabel = stringResource(Res.string.compose_player_sources), + episodesLabel = stringResource(Res.string.compose_player_episodes), + externalPlayerLabel = stringResource(Res.string.streams_open_external_player), + playLabel = stringResource(Res.string.detail_btn_play), + pauseLabel = stringResource(Res.string.compose_action_pause), + closeLabel = stringResource(Res.string.compose_player_close), + lockLabel = stringResource(Res.string.compose_player_lock_controls), + unlockLabel = stringResource(Res.string.compose_player_unlock_controls), + submitIntroLabel = stringResource(Res.string.submit_intro_action), + videoSettingsLabel = stringResource(Res.string.player_action_video_settings), + tapToUnlockLabel = stringResource(Res.string.compose_player_tap_to_unlock), + playbackErrorTitle = stringResource(Res.string.compose_player_playback_error), + playbackErrorMessage = errorMessage.orEmpty(), + playbackErrorActionLabel = stringResource(Res.string.compose_player_go_back), + sourcesPanelTitle = stringResource(Res.string.compose_player_panel_sources), + episodesPanelTitle = stringResource(Res.string.compose_player_panel_episodes), + streamsPanelTitle = stringResource(Res.string.compose_player_panel_streams), + allFilterLabel = allFilterLabel, + reloadLabel = stringResource(Res.string.compose_action_reload), + backLabel = stringResource(Res.string.action_back), + panelCloseLabel = stringResource(Res.string.action_close), + cancelLabel = stringResource(Res.string.action_cancel), + playingLabel = playingLabel, + noStreamsLabel = stringResource(Res.string.compose_player_no_streams_found), + noEpisodesLabel = stringResource(Res.string.compose_player_no_episodes_available), + submitIntroPanelTitle = stringResource(Res.string.submit_intro_title), + submitIntroSegmentTypeLabel = stringResource(Res.string.submit_intro_segment_type_label), + submitIntroSegmentIntroLabel = stringResource(Res.string.submit_intro_segment_intro), + submitIntroSegmentRecapLabel = stringResource(Res.string.submit_intro_segment_recap), + submitIntroSegmentOutroLabel = stringResource(Res.string.submit_intro_segment_outro), + submitIntroStartTimeLabel = stringResource(Res.string.submit_intro_start_time_label), + submitIntroEndTimeLabel = stringResource(Res.string.submit_intro_end_time_label), + submitIntroCaptureLabel = stringResource(Res.string.submit_intro_capture_button), + submitIntroSubmitLabel = stringResource(Res.string.submit_intro_button_submit), + p2pConsentTitle = stringResource(Res.string.p2p_consent_title), + p2pConsentBody = stringResource(Res.string.p2p_consent_body), + p2pConsentEnableLabel = stringResource(Res.string.p2p_consent_enable), + p2pConsentCancelLabel = stringResource(Res.string.p2p_consent_cancel), + subtitlesPanelTitle = stringResource(Res.string.compose_player_subtitles), + subtitleBuiltInTabLabel = stringResource(Res.string.compose_player_built_in), + subtitleAddonsTabLabel = stringResource(Res.string.addon_title), + subtitleStyleTabLabel = stringResource(Res.string.compose_player_style), + noneLabel = stringResource(Res.string.compose_player_none), + fetchSubtitlesLabel = stringResource(Res.string.compose_player_fetch_subtitles), + subtitleDelayLabel = stringResource(Res.string.compose_player_subtitle_delay), + resetLabel = stringResource(Res.string.compose_player_reset), + autoSyncLabel = stringResource(Res.string.compose_player_auto_sync), + reloadSmallLabel = stringResource(Res.string.compose_player_reload), + captureLineLabel = stringResource(Res.string.compose_player_capture_line), + selectAddonSubtitleFirstLabel = stringResource(Res.string.compose_player_select_addon_subtitle_first), + loadingSubtitleLinesLabel = stringResource(Res.string.compose_player_loading_lines), + fontSizeLabel = stringResource(Res.string.compose_player_font_size), + outlineLabel = stringResource(Res.string.compose_player_outline), + boldLabel = stringResource(Res.string.compose_player_bold), + bottomOffsetLabel = stringResource(Res.string.compose_player_bottom_offset), + colorLabel = stringResource(Res.string.compose_player_color), + textOpacityLabel = stringResource(Res.string.compose_player_text_opacity), + outlineColorLabel = stringResource(Res.string.compose_player_outline_color), + resetDefaultsLabel = stringResource(Res.string.compose_player_reset_defaults), + onLabel = stringResource(Res.string.compose_action_on), + offLabel = stringResource(Res.string.compose_action_off), + themeAccentColor = themeColors.accent.toCssColorString(), + themeAccentStrongColor = themeColors.accentStrong.toCssColorString(), + themeOnAccentColor = themeColors.onAccent.toCssColorString(), + themeFocusColor = themeColors.focusRing.toCssColorString(), + themeSelectedSurfaceColor = themeColors.accent.copy(alpha = 0.24f).toCssColorString(), + themeSelectedSurfaceHoverColor = themeColors.accent.copy(alpha = 0.34f).toCssColorString(), + themeSelectedRingColor = themeColors.accent.copy(alpha = 0.35f).toCssColorString(), + themeTimelineFillColor = themeColors.playerTimelineFill.toCssColorString(), + themeTimelineTrackColor = themeColors.playerTimelineTrack.toCssColorString(), + themeBufferingColor = themeColors.playerBuffering.toCssColorString(), + themeBufferingTrackColor = themeColors.playerBuffering.copy(alpha = 0.28f).toCssColorString(), + themeControlForegroundColor = themeColors.playerControlsForeground.toCssColorString(), + isPlaying = playbackSnapshot.isPlaying, + isLoading = playbackSnapshot.isLoading, + isLocked = playerControlsLocked, + lockedOverlayVisible = lockedOverlayVisible, + controlsVisible = controlsVisible && !playerControlsLocked, + parentalWarnings = parentalWarnings, + showParentalGuide = showParentalGuide, + showSubmitIntro = isSeries && + playerSettingsUiState.introSubmitEnabled && + playerSettingsUiState.introDbApiKey.isNotBlank() && + !activeSubmitIntroImdbId().isNullOrBlank(), + showVideoSettings = isIos, + showSources = activeVideoId != null, + showEpisodes = isSeries, + showExternalPlayer = args.onOpenInExternalPlayer != null, + durationMs = playbackSnapshot.durationMs, + positionMs = displayedPositionMs, + sourceIsLoading = sourceStreamsState.isAnyLoading, + sourceFilters = sourceFilters, + sourceItems = sourceItems, + episodeItems = episodeItems, + episodeSeasons = episodeSeasons, + episodeStreamsVisible = episodeStreamsPanelState.showStreams, + episodeStreamsIsLoading = episodeStreamsRepoState.isAnyLoading, + selectedEpisodeLabel = selectedEpisodeLabel, + episodeStreamFilters = episodeStreamFilters, + episodeStreamItems = episodeStreamItems, + submitIntroSegmentType = submitIntroSegmentType, + submitIntroStartTime = submitIntroStartTimeStr, + submitIntroEndTime = submitIntroEndTimeStr, + isSubmitIntroSubmitting = isSubmitIntroSubmitting, + submitIntroStatusMessage = submitIntroStatusMessage.orEmpty(), + showP2pConsent = playerControlsPendingP2pSwitch != null, + subtitleActiveTab = activeSubtitleTab.name, + addonSubtitleItems = playerControlAddonSubtitles, + isLoadingAddonSubtitles = isLoadingAddonSubtitles, + selectedAddonSubtitleId = selectedAddonSubtitleId.orEmpty(), + useCustomSubtitles = useCustomSubtitles, + subtitleStyle = subtitleStyle, + subtitleDelayMs = subtitleDelayMs, + hasSelectedAddonSubtitle = selectedAddonSubtitle != null, + subtitleAutoSyncCapturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: -1L, + subtitleAutoSyncCues = playerControlAutoSyncCues, + subtitleAutoSyncIsLoading = subtitleAutoSyncState.isLoading, + subtitleAutoSyncErrorMessage = subtitleAutoSyncState.errorMessage.orEmpty(), + closeModalsToken = playerControlsCloseModalsToken, + showOpeningOverlay = openingOverlayWanted, + openingArtwork = background ?: poster, + openingLogo = logo, + openingTitle = title, + openingMessage = p2pInitialLoadingMessage, + openingProgress = p2pInitialLoadingProgress, + skipPromptVisible = nativeSkipInterval != null && !playerControlsLocked, + skipPromptLabel = skipPromptLabel(nativeSkipInterval?.type), + skipPromptStartMs = ((nativeSkipInterval?.startTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), + skipPromptEndMs = ((nativeSkipInterval?.endTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), + skipPromptDismissed = skipIntervalDismissed, + nextEpisodeVisible = nextEpisodeForControls != null && !playerControlsLocked, + nextEpisodeHeaderLabel = stringResource(Res.string.player_next_episode), + nextEpisodeTitle = nextEpisodeForControls?.let { + stringResource( + Res.string.compose_player_episode_title_format, + it.season, + it.episode, + it.title, + ) + }.orEmpty(), + nextEpisodeThumbnail = nextEpisodeForControls?.thumbnail.orEmpty(), + nextEpisodeStatus = nextEpisodeStatus, + nextEpisodeActionLabel = if (nextEpisodeForControls?.hasAired == true) { + stringResource(Res.string.detail_btn_play) + } else { + stringResource(Res.string.player_next_episode_unaired) + }, + nextEpisodePlayable = nextEpisodeForControls?.hasAired == true, + ) + } else { + EmptyPlayerControlsState + } val gestureCallbacks = rememberSurfaceGestureCallbacks() Box( @@ -368,15 +377,31 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { resizeMode = resizeMode, initialPositionMs = activeInitialPositionMs.takeIf { isDesktop } ?: 0L, playerControlsState = playerControlsState, - onPlayerControlsAction = { action -> handlePlayerControlsAction(action) }, - onPlayerControlsEvent = { type, value -> handlePlayerControlsEvent(type, value) }, - onPlayerControlsScrubChange = { positionMs -> - handlePlayerControlsScrubChange(positionMs) - true + onPlayerControlsAction = if (isDesktop) { + { action -> handlePlayerControlsAction(action) } + } else { + IgnorePlayerControlsAction }, - onPlayerControlsScrubFinished = { positionMs -> - handlePlayerControlsScrubFinished(positionMs) - true + onPlayerControlsEvent = if (isDesktop) { + { type, value -> handlePlayerControlsEvent(type, value) } + } else { + IgnorePlayerControlsEvent + }, + onPlayerControlsScrubChange = if (isDesktop) { + { positionMs -> + handlePlayerControlsScrubChange(positionMs) + true + } + } else { + IgnorePlayerControlsScrub + }, + onPlayerControlsScrubFinished = if (isDesktop) { + { positionMs -> + handlePlayerControlsScrubFinished(positionMs) + true + } + } else { + IgnorePlayerControlsScrub }, onControllerReady = { controller -> playerController = controller diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index ab76ebc7..e9ae0867 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=76 +CURRENT_PROJECT_VERSION=77 MARKETING_VERSION=0.2.5 From 5249614b5097b221c146583e0d055d2988ce76bf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:00:46 +0530 Subject: [PATCH 3/4] feat: catalog order to be cross-platform --- .../com/nuvio/app/core/sync/SyncPlatform.kt | 2 + .../home/HomeCatalogSettingsSyncService.kt | 152 +++++++++++++++--- 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt index 041ec066..e8111957 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt @@ -1,3 +1,5 @@ package com.nuvio.app.core.sync internal const val MOBILE_SYNC_PLATFORM = "mobile" +internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared" +internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index bddc4c97..c00a41e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -3,7 +3,8 @@ package com.nuvio.app.features.home import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState -import com.nuvio.app.core.sync.MOBILE_SYNC_PLATFORM +import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS +import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest @@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put @Serializable @@ -53,6 +55,14 @@ private data class SupabaseHomeCatalogSettingsBlob( @SerialName("updated_at") val updatedAt: String? = null, ) +private data class RemoteHomeCatalogSettings( + val platform: String, + val payload: SyncHomeCatalogPayload, + val updatedAt: String?, + val hasHideUnreleasedContent: Boolean, + val hasHideCatalogUnderline: Boolean, +) + object HomeCatalogSettingsSyncService { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("HomeCatalogSettingsSyncService") @@ -62,6 +72,8 @@ object HomeCatalogSettingsSyncService { } private const val PUSH_DEBOUNCE_MS = 1500L + private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile var isSyncingFromRemote: Boolean = false @@ -76,31 +88,18 @@ object HomeCatalogSettingsSyncService { suspend fun pullFromServer(profileId: Int) { runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) - val blobs = result.decodeList() - val blob = blobs.firstOrNull() + val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() + val remote = fetchBestRemotePayload(profileId, localPayload) - if (blob == null) { + if (remote == null) { log.i { "pullFromServer — no remote home catalog settings found" } - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() if (localPayload.items.isNotEmpty()) { pushToRemote(profileId) } return } - val remotePayload = runCatching { - json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson) - }.getOrNull() - - if (remotePayload == null) { - log.w { "pullFromServer — failed to parse remote home catalog settings" } - return - } + val remotePayload = remote.payload if (remotePayload.items.isEmpty()) { log.i { "pullFromServer — remote has empty items, preserving local catalog order" } @@ -118,6 +117,9 @@ object HomeCatalogSettingsSyncService { HomeCatalogSettingsRepository.applyFromRemote(remotePayload) isSyncingFromRemote = false log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } + if (remote.platform != HOME_CATALOG_SHARED_SYNC_PLATFORM) { + pushToRemote(profileId) + } }.onFailure { e -> isSyncingFromRemote = false log.e(e) { "pullFromServer — FAILED" } @@ -142,11 +144,11 @@ object HomeCatalogSettingsSyncService { private suspend fun pushToRemote(profileId: Int) { runCatching { val payload = HomeCatalogSettingsRepository.exportToSyncPayload() - val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload) + val jsonElement = mergedSharedPayloadJson(profileId, payload) val params = buildJsonObject { put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) + put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) @@ -172,4 +174,114 @@ object HomeCatalogSettingsSyncService { } } } + + private suspend fun fetchBestRemotePayload( + profileId: Int, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val shared = fetchRemotePayload( + profileId = profileId, + platform = HOME_CATALOG_SHARED_SYNC_PLATFORM, + localPayload = localPayload, + ) + val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS + .mapNotNull { platform -> + fetchRemotePayload( + profileId = profileId, + platform = platform, + localPayload = localPayload, + ) + } + val rows = listOfNotNull(shared) + legacyRows + val selected = rows + .filter { it.payload.items.isNotEmpty() } + .maxByOrNull { it.updatedAt.orEmpty() } + ?: shared + ?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() } + + return selected?.withNewestStandaloneSettings(rows) + } + + private suspend fun fetchRemotePayload( + profileId: Int, + platform: String, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val blob = fetchRemoteBlob(profileId, platform) ?: return null + val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload) + if (payload == null) { + log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" } + return null + } + return RemoteHomeCatalogSettings( + platform = platform, + payload = payload, + updatedAt = blob.updatedAt, + hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), + hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), + ) + } + + private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings( + rows: List, + ): RemoteHomeCatalogSettings { + val hideUnreleasedSource = rows + .filter { it.hasHideUnreleasedContent } + .maxByOrNull { it.updatedAt.orEmpty() } + val hideUnderlineSource = rows + .filter { it.hasHideCatalogUnderline } + .maxByOrNull { it.updatedAt.orEmpty() } + + return copy( + payload = payload.copy( + hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent + ?: payload.hideUnreleasedContent, + hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline + ?: payload.hideCatalogUnderline, + ), + ) + } + + private suspend fun fetchRemoteBlob( + profileId: Int, + platform: String, + ): SupabaseHomeCatalogSettingsBlob? { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", platform) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) + return result.decodeList().firstOrNull() + } + + private fun decodePayloadPreservingLocalDefaults( + settingsJson: JsonObject, + localPayload: SyncHomeCatalogPayload, + ): SyncHomeCatalogPayload? = runCatching { + val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) + decoded.copy( + hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { + decoded.hideUnreleasedContent + } else { + localPayload.hideUnreleasedContent + }, + hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) { + decoded.hideCatalogUnderline + } else { + localPayload.hideCatalogUnderline + }, + ) + }.getOrNull() + + private suspend fun mergedSharedPayloadJson( + profileId: Int, + payload: SyncHomeCatalogPayload, + ): JsonObject { + val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject + val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson + return buildJsonObject { + remoteJson?.forEach { (key, value) -> put(key, value) } + localJson.forEach { (key, value) -> put(key, value) } + } + } } From 21f1f1ebc4c5e6fe2ac900e9e2241303e2a1a5ee Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:09:56 +0530 Subject: [PATCH 4/4] revert readme fixes #1320 --- README.md | 117 +++++++++++++++++++++++++----------------------------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ec96ae4b..7f01b669 100644 --- a/README.md +++ b/README.md @@ -4,89 +4,63 @@

+ [![Contributors][contributors-shield]][contributors-url] + [![Forks][forks-shield]][forks-url] + [![Stargazers][stars-shield]][stars-url] + [![Issues][issues-shield]][issues-url] [![License][license-shield]][license-url]

- Nuvio Desktop is a desktop media hub built with Kotlin Multiplatform and Compose Multiplatform. + A modern media hub for Android and iOS built with Kotlin Multiplatform and Compose Multiplatform.
- Desktop app in development + Stremio addon ecosystem • Cross-platform

## About -Nuvio Desktop brings the Nuvio media experience to desktop. It keeps the playback-focused browsing, collection, watch progress, downloads, and Stremio addon ecosystem integration from Nuvio while adapting the app for desktop input, desktop storage, and native desktop playback. +Nuvio is the current Kotlin Multiplatform rewrite of the original React Native app. It delivers a shared Compose UI for Android and iOS while keeping the playback-focused experience, collection tools, watch progress flows, downloads, and Stremio addon ecosystem integration that shaped the earlier app. -The desktop app is built from the shared Kotlin Multiplatform codebase in [composeApp](./composeApp), with desktop-specific code in [composeApp/src/desktopMain](./composeApp/src/desktopMain). Desktop packaging is configured through Gradle, with development builds active for desktop hosts and broader platform coverage continuing over time. - -## Platform Status - -Current desktop builds are actively being developed. Linux support is planned for a later phase, and public release targets are not finalized yet. +The mobile app is built from a single shared codebase in [composeApp](./composeApp), with native platform entry points for Android and iOS. ## Installation -Public desktop releases are not available yet. Current builds are development builds. +### Android -When releases are ready, desktop builds will be published from the Nuvio Desktop repository. +Download the latest Android build from [GitHub Releases](https://github.com/NuvioMedia/NuvioMobile/releases/latest). + +### iOS + +- [TestFlight](https://testflight.apple.com/join/u4y7MHK9) ## Development -Desktop development checkout: - ```bash -git clone --branch Dev --recurse-submodules https://github.com/NuvioMedia/NuvioDesktop.git -cd NuvioDesktop -git submodule update --init --recursive MPVKit -git -C MPVKit fetch origin Nuvio -git -C MPVKit switch Nuvio -git -C MPVKit pull --ff-only -./gradlew :composeApp:run +git clone https://github.com/NuvioMedia/NuvioMobile.git +cd NuvioMobile +./scripts/run-mobile.sh android +# or +./scripts/run-mobile.sh ios ``` +### Project Structure + +- `composeApp/` contains the shared Kotlin Multiplatform and Compose Multiplatform app code. +- `composeApp/src/commonMain/` contains shared UI, features, repositories, and platform-agnostic logic. +- `composeApp/src/androidMain/` contains Android-specific integrations. +- `composeApp/src/iosMain/` contains iOS-specific integrations. +- `iosApp/` contains the native Xcode project and iOS entry point. + Useful commands: ```bash -./gradlew :composeApp:compileKotlinDesktop -./gradlew :composeApp:packageDistributionForCurrentOS +./gradlew :composeApp:assembleDebug +./gradlew :composeApp:compileKotlinIosSimulatorArm64 +./scripts/build-distribution.sh ``` -On macOS, the native player bridge uses MPVKit libmpv artifacts from the `MPVKit` submodule. For development, the submodule is configured to use [NuvioMedia/MPVKit](https://github.com/NuvioMedia/MPVKit) on the `Nuvio` branch. If you already have a checkout, sync and update it with: - -```bash -git submodule sync MPVKit -git submodule update --init --recursive MPVKit -git -C MPVKit remote set-url origin https://github.com/NuvioMedia/MPVKit.git -git -C MPVKit fetch origin Nuvio -git -C MPVKit switch Nuvio -git -C MPVKit pull --ff-only -``` - -If Gradle reports missing MPVKit artifacts, build the macOS runtime before running the app: - -```bash -cd MPVKit -make build platform=macos -``` - -You can also point Gradle at a separate MPVKit checkout: - -```bash -./gradlew :composeApp:run -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit -``` - -## Project Structure - -- `composeApp/` contains the Kotlin Multiplatform and Compose Multiplatform app code. -- `composeApp/src/commonMain/` contains shared UI, features, repositories, and platform-agnostic logic. -- `composeApp/src/desktopMain/` contains desktop-specific app code, storage, settings, player integration, and desktop resources. -- `composeApp/src/desktopMain/native/macos/` and `composeApp/src/desktopMain/native/windows/` contain the native player bridges. -- `composeApp/src/desktopMain/resources/player-ui/` contains the desktop native player control UI. -- `composeApp/src/desktopMain/resources/icons/` contains desktop app icons for macOS, Windows, and Linux packaging. - -## Desktop Player - -Nuvio Desktop uses a native desktop player path with MPVKit/libmpv integration and desktop-owned controls. The desktop player is separate from the mobile Compose player surface so desktop behavior can match desktop input, keyboard, windowing, and playback expectations. +Versioning is driven from `iosApp/Configuration/Version.xcconfig`, which is used as the shared source of truth for both iOS and Android builds. ## Legal & DMCA @@ -94,15 +68,34 @@ Nuvio functions solely as a client-side interface for browsing metadata and play Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content. -For comprehensive legal information, including the full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit the [Legal & Disclaimer Page](https://nuvioapp.space/legal). +For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our [Legal & Disclaimer Page](https://nuvioapp.space/legal). ## Built With - Kotlin Multiplatform -- Compose Multiplatform for Desktop +- Compose Multiplatform - Kotlin -- MPVKit and libmpv +- AndroidX Media3 +- AVFoundation and native iOS integrations + +## Star History + + + + + + Star History Chart + + -[license-shield]: https://img.shields.io/github/license/NuvioMedia/NuvioDesktop.svg?style=for-the-badge -[license-url]: https://github.com/NuvioMedia/NuvioDesktop/blob/Dev/LICENSE +[contributors-shield]: https://img.shields.io/github/contributors/NuvioMedia/NuvioMobile.svg?style=for-the-badge +[contributors-url]: https://github.com/NuvioMedia/NuvioMobile/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/NuvioMedia/NuvioMobile.svg?style=for-the-badge +[forks-url]: https://github.com/NuvioMedia/NuvioMobile/network/members +[stars-shield]: https://img.shields.io/github/stars/NuvioMedia/NuvioMobile.svg?style=for-the-badge +[stars-url]: https://github.com/NuvioMedia/NuvioMobile/stargazers +[issues-shield]: https://img.shields.io/github/issues/NuvioMedia/NuvioMobile.svg?style=for-the-badge +[issues-url]: https://github.com/NuvioMedia/NuvioMobile/issues +[license-shield]: https://img.shields.io/github/license/NuvioMedia/NuvioMobile.svg?style=for-the-badge +[license-url]: https://github.com/NuvioMedia/NuvioMobile/blob/main/LICENSE \ No newline at end of file