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
+
+
+
+
+
+
+
+
-[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
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) }
+ }
+ }
}
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 3d65887d..e1695797 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(
@@ -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
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index af51dfa7..e9ae0867 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=77
+MARKETING_VERSION=0.2.5