Merge branch 'NuvioMedia:cmp-rewrite' into cmp

This commit is contained in:
paregi12 2026-06-11 13:16:46 +05:30 committed by GitHub
commit f468be5542
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 439 additions and 307 deletions

117
README.md
View file

@ -4,89 +4,63 @@
<br />
<br />
[![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]
<p>
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.
<br />
Desktop app in development
Stremio addon ecosystem • Cross-platform
</p>
</div>
## 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
<a href="https://www.star-history.com/#NuvioMedia/NuvioMobile&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NuvioMedia/NuvioMobile&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NuvioMedia/NuvioMobile&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NuvioMedia/NuvioMobile&type=date&legend=top-left" />
</picture>
</a>
<!-- MARKDOWN LINKS & IMAGES -->
[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

View file

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

View file

@ -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<SupabaseHomeCatalogSettingsBlob>()
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>,
): 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<SupabaseHomeCatalogSettingsBlob>().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) }
}
}
}

View file

@ -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(
@ -369,15 +378,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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=75
MARKETING_VERSION=0.2.4
CURRENT_PROJECT_VERSION=77
MARKETING_VERSION=0.2.5