Merge branch 'NuvioMedia:cmp-rewrite' into indonesian-locale

This commit is contained in:
Luqman Fadlli 2026-05-13 15:17:10 +07:00 committed by GitHub
commit 2638b4fbb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 737 additions and 84 deletions

View file

@ -3,16 +3,4 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nuvio_updater_file_paths" />
</provider>
</application>
</manifest> </manifest>

View file

@ -40,6 +40,16 @@
<receiver <receiver
android:name=".features.downloads.DownloadsNotificationActionReceiver" android:name=".features.downloads.DownloadsNotificationActionReceiver"
android:exported="false" /> android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nuvio_file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -25,6 +25,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage import com.nuvio.app.features.profiles.AvatarStorage
@ -65,6 +66,7 @@ class MainActivity : AppCompatActivity() {
MetaScreenSettingsStorage.initialize(applicationContext) MetaScreenSettingsStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext) HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
ProfileStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext) AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext) ProfilePinCacheStorage.initialize(applicationContext)

View file

@ -0,0 +1,93 @@
package com.nuvio.app.features.player
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.content.FileProvider
import java.io.File
import java.net.URI
private const val AndroidSystemPlayerId = "android_system"
internal actual object ExternalPlayerPlatform {
private var appContext: Context? = null
fun initialize(context: Context) {
appContext = context.applicationContext
}
actual fun defaultPlayerId(): String? = AndroidSystemPlayerId
actual fun availablePlayers(): List<ExternalPlayerApp> =
listOf(ExternalPlayerApp(AndroidSystemPlayerId, "Android system player"))
actual fun open(
request: ExternalPlayerPlaybackRequest,
playerId: String?,
): ExternalPlayerOpenResult {
val context = appContext ?: return ExternalPlayerOpenResult.Failed
val uri = request.sourceUrl.toExternalPlaybackUri(context)
?: return ExternalPlayerOpenResult.Failed
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, request.sourceUrl.videoMimeType())
addCategory(Intent.CATEGORY_DEFAULT)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (uri.scheme.equals("content", ignoreCase = true)) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
putExtra(Intent.EXTRA_TITLE, request.streamTitle ?: request.title)
putExtra("title", request.streamTitle ?: request.title)
if (request.sourceHeaders.isNotEmpty()) {
putExtra("headers", request.sourceHeaders.toAndroidHeadersBundle())
}
}
return try {
context.startActivity(intent)
ExternalPlayerOpenResult.Opened
} catch (_: ActivityNotFoundException) {
ExternalPlayerOpenResult.NoPlayerAvailable
} catch (_: Throwable) {
ExternalPlayerOpenResult.Failed
}
}
private fun String.toExternalPlaybackUri(context: Context): Uri? {
val trimmed = trim()
if (trimmed.isBlank()) return null
if (!trimmed.startsWith("file:", ignoreCase = true)) {
return Uri.parse(trimmed)
}
val localFile = runCatching { File(URI(trimmed)) }.getOrNull() ?: return Uri.parse(trimmed)
return runCatching {
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
localFile,
)
}.getOrNull()
}
}
private fun Map<String, String>.toAndroidHeadersBundle(): Bundle =
Bundle().apply {
forEach { (key, value) ->
putString(key, value)
}
}
private fun String.videoMimeType(): String {
val normalized = substringBefore('?').substringBefore('#').lowercase()
return when {
normalized.endsWith(".m3u8") -> "application/x-mpegURL"
normalized.endsWith(".mpd") -> "application/dash+xml"
normalized.endsWith(".mkv") -> "video/x-matroska"
normalized.endsWith(".webm") -> "video/webm"
normalized.endsWith(".avi") -> "video/x-msvideo"
normalized.endsWith(".mov") -> "video/quicktime"
else -> "video/*"
}
}

View file

@ -23,6 +23,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode" private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value" private const val holdToSpeedValueKey = "hold_to_speed_value"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language" private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@ -59,6 +61,8 @@ actual object PlayerSettingsStorage {
resizeModeKey, resizeModeKey,
holdToSpeedEnabledKey, holdToSpeedEnabledKey,
holdToSpeedValueKey, holdToSpeedValueKey,
externalPlayerEnabledKey,
externalPlayerIdKey,
preferredAudioLanguageKey, preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey, secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey, preferredSubtitleLanguageKey,
@ -157,6 +161,40 @@ actual object PlayerSettingsStorage {
?.apply() ?.apply()
} }
actual fun loadExternalPlayerEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(externalPlayerEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
} else {
null
}
}
actual fun saveExternalPlayerEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(externalPlayerEnabledKey), enabled)
?.apply()
}
actual fun loadExternalPlayerId(): String? =
preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null)
actual fun saveExternalPlayerId(playerId: String?) {
preferences
?.edit()
?.apply {
val key = ProfileScopedKey.of(externalPlayerIdKey)
if (playerId.isNullOrBlank()) {
remove(key)
} else {
putString(key, playerId)
}
}
?.apply()
}
actual fun loadPreferredAudioLanguage(): String? = actual fun loadPreferredAudioLanguage(): String? =
preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null) preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null)
@ -619,6 +657,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@ -659,6 +699,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="nuvio_updates"
path="updates/" />
<files-path
name="nuvio_downloads"
path="downloads/" />
</paths>

View file

@ -689,6 +689,11 @@
<string name="settings_playback_duration_hours">%1$d hours</string> <string name="settings_playback_duration_hours">%1$d hours</string>
<string name="settings_playback_enable_libass">Use libass for ASS/SSA subtitles</string> <string name="settings_playback_enable_libass">Use libass for ASS/SSA subtitles</string>
<string name="settings_playback_enable_libass_description">Experimental: advanced ASS/SSA rendering (styles, positioning, animations)</string> <string name="settings_playback_enable_libass_description">Experimental: advanced ASS/SSA rendering (styles, positioning, animations)</string>
<string name="settings_playback_external_player">External Player</string>
<string name="settings_playback_external_player_app">External Player App</string>
<string name="settings_playback_external_player_description_android">Open new playback with Android&apos;s default video app or system chooser.</string>
<string name="settings_playback_external_player_description_ios">Open new playback with the selected installed player.</string>
<string name="settings_playback_external_player_none_available">No supported external players installed</string>
<string name="settings_playback_hold_speed">Hold Speed</string> <string name="settings_playback_hold_speed">Hold Speed</string>
<string name="settings_playback_hold_to_speed">Hold To Speed</string> <string name="settings_playback_hold_to_speed">Hold To Speed</string>
<string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string> <string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string>
@ -1081,6 +1086,8 @@
<string name="streams_checking_more_addons">Checking more addons…</string> <string name="streams_checking_more_addons">Checking more addons…</string>
<string name="streams_copy_link">Copy stream link</string> <string name="streams_copy_link">Copy stream link</string>
<string name="streams_download_file">Download file</string> <string name="streams_download_file">Download file</string>
<string name="streams_open_external_player">Open in external player</string>
<string name="streams_open_internal_player">Open in internal player</string>
<string name="streams_empty_load_failed_message">The installed stream addons failed to return a valid stream response.</string> <string name="streams_empty_load_failed_message">The installed stream addons failed to return a valid stream response.</string>
<string name="streams_empty_load_failed_title">Could not load streams</string> <string name="streams_empty_load_failed_title">Could not load streams</string>
<string name="streams_empty_no_addons_message">Install an addon first to load streams for this title.</string> <string name="streams_empty_no_addons_message">Install an addon first to load streams for this title.</string>
@ -1101,6 +1108,9 @@
<string name="streams_resume_from_time">Resume from %1$s</string> <string name="streams_resume_from_time">Resume from %1$s</string>
<string name="streams_size">SIZE %1$s</string> <string name="streams_size">SIZE %1$s</string>
<string name="streams_torrent_not_supported">Torrent streams are not supported</string> <string name="streams_torrent_not_supported">Torrent streams are not supported</string>
<string name="external_player_failed">Couldn&apos;t open external player</string>
<string name="external_player_not_configured">Choose an external player in settings first</string>
<string name="external_player_unavailable">No external player is available</string>
<string name="trailer_close">Close trailer</string> <string name="trailer_close">Close trailer</string>
<string name="trailer_unable_to_play">Unable to play trailer</string> <string name="trailer_unable_to_play">Unable to play trailer</string>
<string name="trakt_lists_load_failed">Failed to load Trakt lists</string> <string name="trakt_lists_load_failed">Failed to load Trakt lists</string>

View file

@ -128,6 +128,9 @@ import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.ExternalPlayerOpenResult
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest
import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.AvatarRepository
@ -288,6 +291,14 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
NativeNavigationTab.Settings -> AppScreenTab.Settings NativeNavigationTab.Settings -> AppScreenTab.Settings
} }
private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest =
ExternalPlayerPlaybackRequest(
sourceUrl = sourceUrl,
title = title,
streamTitle = streamTitle,
sourceHeaders = sourceHeaders,
)
private enum class AppGateScreen { private enum class AppGateScreen {
Loading, Loading,
Auth, Auth,
@ -562,6 +573,9 @@ private fun MainAppContent(
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) } var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@ -752,6 +766,29 @@ private fun MainAppContent(
} }
} }
fun openExternalPlayback(launch: PlayerLaunch): Boolean {
return when (
ExternalPlayerPlatform.open(
request = launch.toExternalPlayerPlaybackRequest(),
playerId = playerSettingsUiState.externalPlayerId,
)
) {
ExternalPlayerOpenResult.Opened -> true
ExternalPlayerOpenResult.NotConfigured -> {
NuvioToastController.show(externalPlayerNotConfiguredText)
false
}
ExternalPlayerOpenResult.NoPlayerAvailable -> {
NuvioToastController.show(externalPlayerUnavailableText)
false
}
ExternalPlayerOpenResult.Failed -> {
NuvioToastController.show(externalPlayerFailedText)
false
}
}
}
fun launchPlaybackWithDownloadPreference( fun launchPlaybackWithDownloadPreference(
type: String, type: String,
videoId: String, videoId: String,
@ -783,8 +820,7 @@ private fun MainAppContent(
) )
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) { if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put( val playerLaunch = PlayerLaunch(
PlayerLaunch(
title = title, title = title,
sourceUrl = localSourceUrl, sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(), sourceHeaders = emptyMap(),
@ -807,8 +843,12 @@ private fun MainAppContent(
parentMetaType = parentMetaType, parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs, initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction, initialProgressFraction = targetResumeProgressFraction,
),
) )
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) navController.navigate(PlayerRoute(launchId = launchId))
return return
} }
@ -1348,10 +1388,8 @@ private fun MainAppContent(
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) { if (cached != null) {
reuseNavigated = true
StreamsRepository.clear() StreamsRepository.clear()
val launchId = PlayerLaunchStore.put( val playerLaunch = PlayerLaunch(
PlayerLaunch(
title = launch.title, title = launch.title,
sourceUrl = cached.url, sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
@ -1376,7 +1414,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L, initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction, initialProgressFraction = launch.resumeProgressFraction,
) )
) if (playerSettings.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
reuseNavigated = true
return@LaunchedEffect
}
reuseNavigated = true
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) { navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true } popUpTo<StreamRoute> { inclusive = true }
} }
@ -1428,8 +1472,7 @@ private fun MainAppContent(
bingeGroup = stream.behaviorHints.bingeGroup, bingeGroup = stream.behaviorHints.bingeGroup,
) )
} }
val launchId = PlayerLaunchStore.put( val playerLaunch = PlayerLaunch(
PlayerLaunch(
title = launch.title, title = launch.title,
sourceUrl = sourceUrl, sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
@ -1454,9 +1497,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L, initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction, initialProgressFraction = launch.resumeProgressFraction,
) )
)
StreamsRepository.consumeAutoPlay() StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading() StreamsRepository.cancelLoading()
if (playerSettings.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
return@LaunchedEffect
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) { navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true } popUpTo<StreamRoute> { inclusive = true }
} }
@ -1472,27 +1519,14 @@ private fun MainAppContent(
return@composable return@composable
} }
StreamsScreen( fun openSelectedStream(
type = launch.type, stream: com.nuvio.app.features.streams.StreamItem,
videoId = effectiveVideoId, resolvedResumePositionMs: Long?,
parentMetaId = launch.parentMetaId ?: effectiveVideoId, resolvedResumeProgressFraction: Float?,
parentMetaType = launch.parentMetaType ?: launch.type, forceExternal: Boolean,
title = launch.title, forceInternal: Boolean,
logo = launch.logo, ) {
poster = launch.poster, val sourceUrl = stream.directPlaybackUrl ?: return
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
val sourceUrl = stream.directPlaybackUrl
if (sourceUrl != null) {
// Persist for Reuse Last Link
if (playerSettings.streamReuseLastLinkEnabled) { if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey( val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type, type = launch.type,
@ -1514,8 +1548,7 @@ private fun MainAppContent(
bingeGroup = stream.behaviorHints.bingeGroup, bingeGroup = stream.behaviorHints.bingeGroup,
) )
} }
val launchId = PlayerLaunchStore.put( val playerLaunch = PlayerLaunch(
PlayerLaunch(
title = launch.title, title = launch.title,
sourceUrl = sourceUrl, sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
@ -1540,12 +1573,54 @@ private fun MainAppContent(
initialPositionMs = resolvedResumePositionMs ?: 0L, initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction, initialProgressFraction = resolvedResumeProgressFraction,
) )
)
if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
openExternalPlayback(playerLaunch)
StreamsRepository.cancelLoading()
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading() StreamsRepository.cancelLoading()
navController.navigate( navController.navigate(
PlayerRoute(launchId = launchId) PlayerRoute(launchId = launchId)
) )
} }
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
}, },
onBack = { onBack = {
StreamsRepository.clear() StreamsRepository.clear()
@ -1674,8 +1749,7 @@ private fun MainAppContent(
?.let(WatchProgressRepository::progressForVideo) ?.let(WatchProgressRepository::progressForVideo)
?.takeIf { it.isResumable } ?.takeIf { it.isResumable }
val launchId = PlayerLaunchStore.put( val playerLaunch = PlayerLaunch(
PlayerLaunch(
title = item.title, title = item.title,
sourceUrl = sourceUrl, sourceUrl = sourceUrl,
sourceHeaders = emptyMap(), sourceHeaders = emptyMap(),
@ -1697,8 +1771,12 @@ private fun MainAppContent(
parentMetaType = item.parentMetaType, parentMetaType = item.parentMetaType,
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
),
) )
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
return@DownloadsScreen
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) navController.navigate(PlayerRoute(launchId = launchId))
}, },
) )

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.player
data class ExternalPlayerApp(
val id: String,
val name: String,
)
data class ExternalPlayerPlaybackRequest(
val sourceUrl: String,
val title: String,
val streamTitle: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
)
enum class ExternalPlayerOpenResult {
Opened,
NotConfigured,
NoPlayerAvailable,
Failed,
}
internal expect object ExternalPlayerPlatform {
fun defaultPlayerId(): String?
fun availablePlayers(): List<ExternalPlayerApp>
fun open(
request: ExternalPlayerPlaybackRequest,
playerId: String?,
): ExternalPlayerOpenResult
}

View file

@ -13,6 +13,8 @@ data class PlayerSettingsUiState(
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
val holdToSpeedEnabled: Boolean = true, val holdToSpeedEnabled: Boolean = true,
val holdToSpeedValue: Float = 2f, val holdToSpeedValue: Float = 2f,
val externalPlayerEnabled: Boolean = false,
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null, val secondaryPreferredAudioLanguage: String? = null,
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE, val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
@ -52,6 +54,8 @@ object PlayerSettingsRepository {
private var resizeMode = PlayerResizeMode.Fit private var resizeMode = PlayerResizeMode.Fit
private var holdToSpeedEnabled = true private var holdToSpeedEnabled = true
private var holdToSpeedValue = 2f private var holdToSpeedValue = 2f
private var externalPlayerEnabled = false
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
private var preferredAudioLanguage = AudioLanguageOption.DEVICE private var preferredAudioLanguage = AudioLanguageOption.DEVICE
private var secondaryPreferredAudioLanguage: String? = null private var secondaryPreferredAudioLanguage: String? = null
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@ -96,6 +100,8 @@ object PlayerSettingsRepository {
resizeMode = PlayerResizeMode.Fit resizeMode = PlayerResizeMode.Fit
holdToSpeedEnabled = true holdToSpeedEnabled = true
holdToSpeedValue = 2f holdToSpeedValue = 2f
externalPlayerEnabled = false
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage = AudioLanguageOption.DEVICE preferredAudioLanguage = AudioLanguageOption.DEVICE
secondaryPreferredAudioLanguage = null secondaryPreferredAudioLanguage = null
preferredSubtitleLanguage = SubtitleLanguageOption.NONE preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@ -135,6 +141,9 @@ object PlayerSettingsRepository {
?: PlayerResizeMode.Fit ?: PlayerResizeMode.Fit
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
?: ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage = preferredAudioLanguage =
normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage()) normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage())
?: AudioLanguageOption.DEVICE ?: AudioLanguageOption.DEVICE
@ -231,6 +240,31 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveHoldToSpeedValue(normalized) PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
} }
fun setExternalPlayerEnabled(enabled: Boolean) {
ensureLoaded()
if (enabled && externalPlayerId.isNullOrBlank()) {
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
?: ExternalPlayerPlatform.availablePlayers().firstOrNull()?.id
PlayerSettingsStorage.saveExternalPlayerId(externalPlayerId)
}
if (externalPlayerEnabled == enabled) {
publish()
return
}
externalPlayerEnabled = enabled
publish()
PlayerSettingsStorage.saveExternalPlayerEnabled(enabled)
}
fun setExternalPlayerId(playerId: String?) {
ensureLoaded()
val normalized = playerId?.takeIf { it.isNotBlank() }
if (externalPlayerId == normalized) return
externalPlayerId = normalized
publish()
PlayerSettingsStorage.saveExternalPlayerId(normalized)
}
fun setPreferredAudioLanguage(language: String) { fun setPreferredAudioLanguage(language: String) {
ensureLoaded() ensureLoaded()
val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE
@ -470,6 +504,8 @@ object PlayerSettingsRepository {
resizeMode = resizeMode, resizeMode = resizeMode,
holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedEnabled = holdToSpeedEnabled,
holdToSpeedValue = holdToSpeedValue, holdToSpeedValue = holdToSpeedValue,
externalPlayerEnabled = externalPlayerEnabled,
externalPlayerId = externalPlayerId,
preferredAudioLanguage = preferredAudioLanguage, preferredAudioLanguage = preferredAudioLanguage,
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
preferredSubtitleLanguage = preferredSubtitleLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage,

View file

@ -11,6 +11,10 @@ internal expect object PlayerSettingsStorage {
fun saveHoldToSpeedEnabled(enabled: Boolean) fun saveHoldToSpeedEnabled(enabled: Boolean)
fun loadHoldToSpeedValue(): Float? fun loadHoldToSpeedValue(): Float?
fun saveHoldToSpeedValue(speed: Float) fun saveHoldToSpeedValue(speed: Float)
fun loadExternalPlayerEnabled(): Boolean?
fun saveExternalPlayerEnabled(enabled: Boolean)
fun loadExternalPlayerId(): String?
fun saveExternalPlayerId(playerId: String?)
fun loadPreferredAudioLanguage(): String? fun loadPreferredAudioLanguage(): String?
fun savePreferredAudioLanguage(language: String) fun savePreferredAudioLanguage(language: String)
fun loadSecondaryPreferredAudioLanguage(): String? fun loadSecondaryPreferredAudioLanguage(): String?

View file

@ -53,6 +53,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.formatPlaybackSpeedLabel
@ -169,6 +171,7 @@ private fun PlaybackSettingsSection(
var showSecondaryAudioDialog by remember { mutableStateOf(false) } var showSecondaryAudioDialog by remember { mutableStateOf(false) }
var showPreferredSubtitleDialog by remember { mutableStateOf(false) } var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
var showSecondarySubtitleDialog by remember { mutableStateOf(false) } var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
var showExternalPlayerDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
@ -180,6 +183,10 @@ private fun PlaybackSettingsSection(
var showAutoPlayRegexDialog by remember { mutableStateOf(false) } var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
val availableExternalPlayers = ExternalPlayerPlatform.availablePlayers()
val selectedExternalPlayer = availableExternalPlayers.firstOrNull {
it.id == autoPlayPlayerSettings.externalPlayerId
}
val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val pluginUiState = if (pluginsEnabled) { val pluginUiState = if (pluginsEnabled) {
val state by PluginRepository.uiState.collectAsStateWithLifecycle() val state by PluginRepository.uiState.collectAsStateWithLifecycle()
@ -206,6 +213,39 @@ private fun PlaybackSettingsSection(
onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay,
) )
SettingsGroupDivider(isTablet = isTablet) SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_external_player),
description = stringResource(
if (isIos) {
Res.string.settings_playback_external_player_description_ios
} else {
Res.string.settings_playback_external_player_description_android
},
),
checked = autoPlayPlayerSettings.externalPlayerEnabled,
isTablet = isTablet,
onCheckedChange = { enabled ->
PlayerSettingsRepository.setExternalPlayerEnabled(enabled)
if (enabled && isIos) {
showExternalPlayerDialog = true
}
},
)
if (isIos && autoPlayPlayerSettings.externalPlayerEnabled) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_external_player_app),
description = selectedExternalPlayer?.name
?: if (availableExternalPlayers.isEmpty()) {
stringResource(Res.string.settings_playback_external_player_none_available)
} else {
stringResource(Res.string.settings_playback_not_set)
},
isTablet = isTablet,
onClick = { showExternalPlayerDialog = true },
)
}
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow( SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_hold_to_speed), title = stringResource(Res.string.settings_playback_hold_to_speed),
description = stringResource(Res.string.settings_playback_hold_to_speed_description), description = stringResource(Res.string.settings_playback_hold_to_speed_description),
@ -780,6 +820,18 @@ private fun PlaybackSettingsSection(
) )
} }
if (showExternalPlayerDialog) {
ExternalPlayerSelectionDialog(
players = availableExternalPlayers,
selectedPlayerId = autoPlayPlayerSettings.externalPlayerId,
onPlayerSelected = { playerId ->
PlayerSettingsRepository.setExternalPlayerId(playerId)
showExternalPlayerDialog = false
},
onDismiss = { showExternalPlayerDialog = false },
)
}
if (showDecoderPriorityDialog) { if (showDecoderPriorityDialog) {
DecoderPriorityDialog( DecoderPriorityDialog(
selectedPriority = decoderPriority, selectedPriority = decoderPriority,
@ -904,6 +956,100 @@ private data class LanguageSelectionOption(
val label: String, val label: String,
) )
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ExternalPlayerSelectionDialog(
players: List<ExternalPlayerApp>,
selectedPlayerId: String?,
onPlayerSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_playback_external_player_app),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
if (players.isEmpty()) {
Text(
text = stringResource(Res.string.settings_playback_external_player_none_available),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
players.forEach { player ->
val isSelected = player.id == selectedPlayerId
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onPlayerSelected(player.id) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = player.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun LanguageSelectionDialog( private fun LanguageSelectionDialog(

View file

@ -427,12 +427,21 @@ internal fun settingsSearchEntries(
pageLabel = playbackPage, pageLabel = playbackPage,
section = playbackPlayer, section = playbackPlayer,
icon = Icons.Rounded.PlayArrow, icon = Icons.Rounded.PlayArrow,
rows = listOf( rows = listOfNotNull(
PlaybackSearchRow( PlaybackSearchRow(
"loading-overlay", "loading-overlay",
stringResource(Res.string.settings_playback_show_loading_overlay), stringResource(Res.string.settings_playback_show_loading_overlay),
stringResource(Res.string.settings_playback_show_loading_overlay_description), stringResource(Res.string.settings_playback_show_loading_overlay_description),
), ),
PlaybackSearchRow(
"external-player",
stringResource(Res.string.settings_playback_external_player),
stringResource(Res.string.settings_playback_external_player_description_android),
),
if (isIos) PlaybackSearchRow(
"external-player-app",
stringResource(Res.string.settings_playback_external_player_app),
) else null,
PlaybackSearchRow( PlaybackSearchRow(
"hold-to-speed", "hold-to-speed",
stringResource(Res.string.settings_playback_hold_to_speed), stringResource(Res.string.settings_playback_hold_to_speed),

View file

@ -38,6 +38,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
@ -84,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.round import kotlin.math.round
@ -114,10 +116,20 @@ fun StreamsScreen(
manualSelection: Boolean = false, manualSelection: Boolean = false,
startFromBeginning: Boolean = false, startFromBeginning: Boolean = false,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> }, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
onStreamActionOpen: (
stream: StreamItem,
openExternally: Boolean,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
) -> Unit = { _, _, _, _ -> },
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val playerSettings by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember { val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded() WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState WatchProgressRepository.uiState
@ -323,6 +335,7 @@ fun StreamsScreen(
StreamActionsSheet( StreamActionsSheet(
stream = streamActionsTarget, stream = streamActionsTarget,
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
onDismiss = { streamActionsTarget = null }, onDismiss = { streamActionsTarget = null },
onCopyLink = { stream -> onCopyLink = { stream ->
val directUrl = stream.directPlaybackUrl val directUrl = stream.directPlaybackUrl
@ -351,6 +364,14 @@ fun StreamsScreen(
) )
NuvioToastController.show(result.toastMessage()) NuvioToastController.show(result.toastMessage())
}, },
onOpen = { stream, openExternally ->
onStreamActionOpen(
stream,
openExternally,
effectiveResumePositionMs,
effectiveResumeProgressFraction,
)
},
) )
} }
} }
@ -1008,9 +1029,11 @@ private fun StreamCard(
@Composable @Composable
private fun StreamActionsSheet( private fun StreamActionsSheet(
stream: StreamItem?, stream: StreamItem?,
externalPlayerEnabled: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCopyLink: (StreamItem) -> Unit, onCopyLink: (StreamItem) -> Unit,
onDownload: (StreamItem) -> Unit, onDownload: (StreamItem) -> Unit,
onOpen: (StreamItem, openExternally: Boolean) -> Unit,
) { ) {
if (stream == null) return if (stream == null) return
@ -1069,6 +1092,23 @@ private fun StreamActionsSheet(
}, },
) )
NuvioBottomSheetDivider() NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.AutoMirrored.Rounded.OpenInNew,
title = stringResource(
if (externalPlayerEnabled) {
Res.string.streams_open_internal_player
} else {
Res.string.streams_open_external_player
},
),
onClick = {
onOpen(stream, !externalPlayerEnabled)
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow( NuvioBottomSheetActionRow(
icon = Icons.Rounded.Download, icon = Icons.Rounded.Download,
title = stringResource(Res.string.streams_download_file), title = stringResource(Res.string.streams_download_file),

View file

@ -0,0 +1,112 @@
package com.nuvio.app.features.player
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
private data class IosExternalPlayerSpec(
val id: String,
val name: String,
val scheme: String,
val buildUrl: (ExternalPlayerPlaybackRequest) -> String,
)
private val iosExternalPlayerSpecs = listOf(
IosExternalPlayerSpec(
id = "infuse",
name = "Infuse",
scheme = "infuse",
buildUrl = { request ->
buildString {
append("infuse://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
}
},
),
IosExternalPlayerSpec(
id = "vlc",
name = "VLC",
scheme = "vlc-x-callback",
buildUrl = { request ->
"vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}"
},
),
IosExternalPlayerSpec(
id = "outplayer",
name = "Outplayer",
scheme = "outplayer",
buildUrl = { request ->
buildString {
append("outplayer://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
}
},
),
IosExternalPlayerSpec(
id = "vidhub",
name = "VidHub",
scheme = "open-vidhub",
buildUrl = { request ->
"open-vidhub://x-callback-url/open?url=${request.sourceUrl.urlQueryEncode()}"
},
),
)
internal actual object ExternalPlayerPlatform {
actual fun defaultPlayerId(): String? = null
actual fun availablePlayers(): List<ExternalPlayerApp> =
iosExternalPlayerSpecs
.filter { spec -> UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl()) }
.map { spec -> ExternalPlayerApp(spec.id, spec.name) }
actual fun open(
request: ExternalPlayerPlaybackRequest,
playerId: String?,
): ExternalPlayerOpenResult {
if (playerId.isNullOrBlank()) return ExternalPlayerOpenResult.NotConfigured
val spec = iosExternalPlayerSpecs.firstOrNull { it.id == playerId }
?: return ExternalPlayerOpenResult.NotConfigured
if (!UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl())) {
return ExternalPlayerOpenResult.NoPlayerAvailable
}
val url = NSURL.URLWithString(spec.buildUrl(request))
?: return ExternalPlayerOpenResult.Failed
UIApplication.sharedApplication.openURL(
url = url,
options = emptyMap<Any?, Any>(),
completionHandler = null,
)
return ExternalPlayerOpenResult.Opened
}
}
private fun IosExternalPlayerSpec.schemeProbeUrl(): NSURL =
NSURL.URLWithString("$scheme://") ?: NSURL.URLWithString("nuvio://")!!
private fun String.urlQueryEncode(): String {
val hex = "0123456789ABCDEF"
return buildString {
encodeToByteArray().forEach { byte ->
val value = byte.toInt() and 0xFF
val char = value.toChar()
val safe = char in 'A'..'Z' ||
char in 'a'..'z' ||
char in '0'..'9' ||
char == '-' ||
char == '_' ||
char == '.' ||
char == '~'
if (safe) {
append(char)
} else {
append('%')
append(hex[value ushr 4])
append(hex[value and 0x0F])
}
}
}
}

View file

@ -21,6 +21,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode" private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value" private const val holdToSpeedValueKey = "hold_to_speed_value"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language" private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@ -57,6 +59,8 @@ actual object PlayerSettingsStorage {
resizeModeKey, resizeModeKey,
holdToSpeedEnabledKey, holdToSpeedEnabledKey,
holdToSpeedValueKey, holdToSpeedValueKey,
externalPlayerEnabledKey,
externalPlayerIdKey,
preferredAudioLanguageKey, preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey, secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey, preferredSubtitleLanguageKey,
@ -140,6 +144,36 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey)) NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey))
} }
actual fun loadExternalPlayerEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(externalPlayerEnabledKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveExternalPlayerEnabled(enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerEnabledKey))
}
actual fun loadExternalPlayerId(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(externalPlayerIdKey)
return defaults.stringForKey(key)
}
actual fun saveExternalPlayerId(playerId: String?) {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(externalPlayerIdKey)
if (playerId.isNullOrBlank()) {
defaults.removeObjectForKey(key)
} else {
defaults.setObject(playerId, forKey = key)
}
}
actual fun loadPreferredAudioLanguage(): String? { actual fun loadPreferredAudioLanguage(): String? {
val defaults = NSUserDefaults.standardUserDefaults val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(preferredAudioLanguageKey) val key = ProfileScopedKey.of(preferredAudioLanguageKey)
@ -523,6 +557,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@ -563,6 +599,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)

View file

@ -22,6 +22,13 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>infuse</string>
<string>vlc-x-callback</string>
<string>outplayer</string>
<string>open-vidhub</string>
</array>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
</dict> </dict>