mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into indonesian-locale
This commit is contained in:
commit
2638b4fbb5
17 changed files with 737 additions and 84 deletions
|
|
@ -3,16 +3,4 @@
|
|||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@
|
|||
<receiver
|
||||
android:name=".features.downloads.DownloadsNotificationActionReceiver"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
|
|||
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
|
||||
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
|
||||
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.plugins.PluginStorage
|
||||
import com.nuvio.app.features.profiles.AvatarStorage
|
||||
|
|
@ -65,6 +66,7 @@ class MainActivity : AppCompatActivity() {
|
|||
MetaScreenSettingsStorage.initialize(applicationContext)
|
||||
HomeCatalogSettingsStorage.initialize(applicationContext)
|
||||
PlayerSettingsStorage.initialize(applicationContext)
|
||||
ExternalPlayerPlatform.initialize(applicationContext)
|
||||
ProfileStorage.initialize(applicationContext)
|
||||
AvatarStorage.initialize(applicationContext)
|
||||
ProfilePinCacheStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -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/*"
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val resizeModeKey = "resize_mode"
|
||||
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
|
||||
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 secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
|
||||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
|
|
@ -59,6 +61,8 @@ actual object PlayerSettingsStorage {
|
|||
resizeModeKey,
|
||||
holdToSpeedEnabledKey,
|
||||
holdToSpeedValueKey,
|
||||
externalPlayerEnabledKey,
|
||||
externalPlayerIdKey,
|
||||
preferredAudioLanguageKey,
|
||||
secondaryPreferredAudioLanguageKey,
|
||||
preferredSubtitleLanguageKey,
|
||||
|
|
@ -157,6 +161,40 @@ actual object PlayerSettingsStorage {
|
|||
?.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? =
|
||||
preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null)
|
||||
|
||||
|
|
@ -619,6 +657,8 @@ actual object PlayerSettingsStorage {
|
|||
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
|
||||
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(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)) }
|
||||
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
|
||||
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
|
|
@ -659,6 +699,8 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
|
||||
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
|
||||
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
|
||||
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
|
||||
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
|
||||
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
|
||||
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
|
||||
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
|
||||
|
|
|
|||
9
composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
Normal file
9
composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
Normal 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>
|
||||
|
|
@ -689,6 +689,11 @@
|
|||
<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_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'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_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>
|
||||
|
|
@ -1081,6 +1086,8 @@
|
|||
<string name="streams_checking_more_addons">Checking more addons…</string>
|
||||
<string name="streams_copy_link">Copy stream link</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_title">Could not load streams</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_size">SIZE %1$s</string>
|
||||
<string name="streams_torrent_not_supported">Torrent streams are not supported</string>
|
||||
<string name="external_player_failed">Couldn'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_unable_to_play">Unable to play trailer</string>
|
||||
<string name="trakt_lists_load_failed">Failed to load Trakt lists</string>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ import com.nuvio.app.features.player.PlayerLaunch
|
|||
import com.nuvio.app.features.player.PlayerLaunchStore
|
||||
import com.nuvio.app.features.player.PlayerRoute
|
||||
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.sanitizePlaybackResponseHeaders
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
|
|
@ -288,6 +291,14 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
|
|||
NativeNavigationTab.Settings -> AppScreenTab.Settings
|
||||
}
|
||||
|
||||
private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest =
|
||||
ExternalPlayerPlaybackRequest(
|
||||
sourceUrl = sourceUrl,
|
||||
title = title,
|
||||
streamTitle = streamTitle,
|
||||
sourceHeaders = sourceHeaders,
|
||||
)
|
||||
|
||||
private enum class AppGateScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
|
|
@ -562,6 +573,9 @@ private fun MainAppContent(
|
|||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
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
|
||||
var initialHomeReady 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(
|
||||
type: String,
|
||||
videoId: String,
|
||||
|
|
@ -783,8 +820,7 @@ private fun MainAppContent(
|
|||
)
|
||||
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
|
||||
if (!localSourceUrl.isNullOrBlank()) {
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = title,
|
||||
sourceUrl = localSourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
|
|
@ -807,8 +843,12 @@ private fun MainAppContent(
|
|||
parentMetaType = parentMetaType,
|
||||
initialPositionMs = targetResumePositionMs,
|
||||
initialProgressFraction = targetResumeProgressFraction,
|
||||
),
|
||||
)
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
openExternalPlayback(playerLaunch)
|
||||
return
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
return
|
||||
}
|
||||
|
|
@ -1348,10 +1388,8 @@ private fun MainAppContent(
|
|||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||
if (cached != null) {
|
||||
reuseNavigated = true
|
||||
StreamsRepository.clear()
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = launch.title,
|
||||
sourceUrl = cached.url,
|
||||
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
|
||||
|
|
@ -1376,7 +1414,13 @@ private fun MainAppContent(
|
|||
initialPositionMs = launch.resumePositionMs ?: 0L,
|
||||
initialProgressFraction = launch.resumeProgressFraction,
|
||||
)
|
||||
)
|
||||
if (playerSettings.externalPlayerEnabled) {
|
||||
openExternalPlayback(playerLaunch)
|
||||
reuseNavigated = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
reuseNavigated = true
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId)) {
|
||||
popUpTo<StreamRoute> { inclusive = true }
|
||||
}
|
||||
|
|
@ -1428,8 +1472,7 @@ private fun MainAppContent(
|
|||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = launch.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
|
|
@ -1454,9 +1497,13 @@ private fun MainAppContent(
|
|||
initialPositionMs = launch.resumePositionMs ?: 0L,
|
||||
initialProgressFraction = launch.resumeProgressFraction,
|
||||
)
|
||||
)
|
||||
StreamsRepository.consumeAutoPlay()
|
||||
StreamsRepository.cancelLoading()
|
||||
if (playerSettings.externalPlayerEnabled) {
|
||||
openExternalPlayback(playerLaunch)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId)) {
|
||||
popUpTo<StreamRoute> { inclusive = true }
|
||||
}
|
||||
|
|
@ -1472,6 +1519,74 @@ private fun MainAppContent(
|
|||
return@composable
|
||||
}
|
||||
|
||||
fun openSelectedStream(
|
||||
stream: com.nuvio.app.features.streams.StreamItem,
|
||||
resolvedResumePositionMs: Long?,
|
||||
resolvedResumeProgressFraction: Float?,
|
||||
forceExternal: Boolean,
|
||||
forceInternal: Boolean,
|
||||
) {
|
||||
val sourceUrl = stream.directPlaybackUrl ?: return
|
||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
type = launch.type,
|
||||
videoId = effectiveVideoId,
|
||||
parentMetaId = launch.parentMetaId,
|
||||
season = launch.seasonNumber,
|
||||
episode = launch.episodeNumber,
|
||||
)
|
||||
StreamLinkCacheRepository.save(
|
||||
contentKey = cacheKey,
|
||||
url = sourceUrl,
|
||||
streamName = stream.streamLabel,
|
||||
addonName = stream.addonName,
|
||||
addonId = stream.addonId,
|
||||
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
}
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = launch.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
background = launch.background,
|
||||
seasonNumber = launch.seasonNumber,
|
||||
episodeNumber = launch.episodeNumber,
|
||||
episodeTitle = launch.episodeTitle,
|
||||
episodeThumbnail = launch.episodeThumbnail,
|
||||
streamTitle = stream.streamLabel,
|
||||
streamSubtitle = stream.streamSubtitle,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
pauseDescription = pauseDescription,
|
||||
providerName = stream.addonName,
|
||||
providerAddonId = stream.addonId,
|
||||
contentType = launch.type,
|
||||
videoId = effectiveVideoId,
|
||||
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
||||
parentMetaType = launch.parentMetaType ?: launch.type,
|
||||
initialPositionMs = resolvedResumePositionMs ?: 0L,
|
||||
initialProgressFraction = resolvedResumeProgressFraction,
|
||||
)
|
||||
|
||||
if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
|
||||
openExternalPlayback(playerLaunch)
|
||||
StreamsRepository.cancelLoading()
|
||||
return
|
||||
}
|
||||
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
StreamsRepository.cancelLoading()
|
||||
navController.navigate(
|
||||
PlayerRoute(launchId = launchId)
|
||||
)
|
||||
}
|
||||
|
||||
StreamsScreen(
|
||||
type = launch.type,
|
||||
videoId = effectiveVideoId,
|
||||
|
|
@ -1490,62 +1605,22 @@ private fun MainAppContent(
|
|||
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) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
type = launch.type,
|
||||
videoId = effectiveVideoId,
|
||||
parentMetaId = launch.parentMetaId,
|
||||
season = launch.seasonNumber,
|
||||
episode = launch.episodeNumber,
|
||||
)
|
||||
StreamLinkCacheRepository.save(
|
||||
contentKey = cacheKey,
|
||||
url = sourceUrl,
|
||||
streamName = stream.streamLabel,
|
||||
addonName = stream.addonName,
|
||||
addonId = stream.addonId,
|
||||
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = launch.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
background = launch.background,
|
||||
seasonNumber = launch.seasonNumber,
|
||||
episodeNumber = launch.episodeNumber,
|
||||
episodeTitle = launch.episodeTitle,
|
||||
episodeThumbnail = launch.episodeThumbnail,
|
||||
streamTitle = stream.streamLabel,
|
||||
streamSubtitle = stream.streamSubtitle,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
pauseDescription = pauseDescription,
|
||||
providerName = stream.addonName,
|
||||
providerAddonId = stream.addonId,
|
||||
contentType = launch.type,
|
||||
videoId = effectiveVideoId,
|
||||
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
||||
parentMetaType = launch.parentMetaType ?: launch.type,
|
||||
initialPositionMs = resolvedResumePositionMs ?: 0L,
|
||||
initialProgressFraction = resolvedResumeProgressFraction,
|
||||
)
|
||||
)
|
||||
StreamsRepository.cancelLoading()
|
||||
navController.navigate(
|
||||
PlayerRoute(launchId = launchId)
|
||||
)
|
||||
}
|
||||
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 = {
|
||||
StreamsRepository.clear()
|
||||
|
|
@ -1674,8 +1749,7 @@ private fun MainAppContent(
|
|||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
|
|
@ -1697,8 +1771,12 @@ private fun MainAppContent(
|
|||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
),
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
openExternalPlayback(playerLaunch)
|
||||
return@DownloadsScreen
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ data class PlayerSettingsUiState(
|
|||
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
|
||||
val holdToSpeedEnabled: Boolean = true,
|
||||
val holdToSpeedValue: Float = 2f,
|
||||
val externalPlayerEnabled: Boolean = false,
|
||||
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
|
||||
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
|
||||
val secondaryPreferredAudioLanguage: String? = null,
|
||||
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
|
||||
|
|
@ -52,6 +54,8 @@ object PlayerSettingsRepository {
|
|||
private var resizeMode = PlayerResizeMode.Fit
|
||||
private var holdToSpeedEnabled = true
|
||||
private var holdToSpeedValue = 2f
|
||||
private var externalPlayerEnabled = false
|
||||
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
|
||||
private var preferredAudioLanguage = AudioLanguageOption.DEVICE
|
||||
private var secondaryPreferredAudioLanguage: String? = null
|
||||
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
|
||||
|
|
@ -96,6 +100,8 @@ object PlayerSettingsRepository {
|
|||
resizeMode = PlayerResizeMode.Fit
|
||||
holdToSpeedEnabled = true
|
||||
holdToSpeedValue = 2f
|
||||
externalPlayerEnabled = false
|
||||
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
|
||||
preferredAudioLanguage = AudioLanguageOption.DEVICE
|
||||
secondaryPreferredAudioLanguage = null
|
||||
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
|
||||
|
|
@ -135,6 +141,9 @@ object PlayerSettingsRepository {
|
|||
?: PlayerResizeMode.Fit
|
||||
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
|
||||
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
|
||||
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
|
||||
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
|
||||
?: ExternalPlayerPlatform.defaultPlayerId()
|
||||
preferredAudioLanguage =
|
||||
normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage())
|
||||
?: AudioLanguageOption.DEVICE
|
||||
|
|
@ -231,6 +240,31 @@ object PlayerSettingsRepository {
|
|||
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) {
|
||||
ensureLoaded()
|
||||
val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE
|
||||
|
|
@ -470,6 +504,8 @@ object PlayerSettingsRepository {
|
|||
resizeMode = resizeMode,
|
||||
holdToSpeedEnabled = holdToSpeedEnabled,
|
||||
holdToSpeedValue = holdToSpeedValue,
|
||||
externalPlayerEnabled = externalPlayerEnabled,
|
||||
externalPlayerId = externalPlayerId,
|
||||
preferredAudioLanguage = preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ internal expect object PlayerSettingsStorage {
|
|||
fun saveHoldToSpeedEnabled(enabled: Boolean)
|
||||
fun loadHoldToSpeedValue(): Float?
|
||||
fun saveHoldToSpeedValue(speed: Float)
|
||||
fun loadExternalPlayerEnabled(): Boolean?
|
||||
fun saveExternalPlayerEnabled(enabled: Boolean)
|
||||
fun loadExternalPlayerId(): String?
|
||||
fun saveExternalPlayerId(playerId: String?)
|
||||
fun loadPreferredAudioLanguage(): String?
|
||||
fun savePreferredAudioLanguage(language: String)
|
||||
fun loadSecondaryPreferredAudioLanguage(): String?
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.player.AudioLanguageOption
|
||||
import com.nuvio.app.features.player.AvailableLanguageOptions
|
||||
import com.nuvio.app.features.player.ExternalPlayerApp
|
||||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.player.SubtitleLanguageOption
|
||||
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
|
||||
|
|
@ -169,6 +171,7 @@ private fun PlaybackSettingsSection(
|
|||
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
|
||||
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showExternalPlayerDialog by remember { mutableStateOf(false) }
|
||||
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
|
||||
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
|
||||
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -180,6 +183,10 @@ private fun PlaybackSettingsSection(
|
|||
var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
|
||||
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
|
||||
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 pluginUiState = if (pluginsEnabled) {
|
||||
val state by PluginRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -206,6 +213,39 @@ private fun PlaybackSettingsSection(
|
|||
onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay,
|
||||
)
|
||||
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(
|
||||
title = stringResource(Res.string.settings_playback_hold_to_speed),
|
||||
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) {
|
||||
DecoderPriorityDialog(
|
||||
selectedPriority = decoderPriority,
|
||||
|
|
@ -904,6 +956,100 @@ private data class LanguageSelectionOption(
|
|||
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
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun LanguageSelectionDialog(
|
||||
|
|
|
|||
|
|
@ -427,12 +427,21 @@ internal fun settingsSearchEntries(
|
|||
pageLabel = playbackPage,
|
||||
section = playbackPlayer,
|
||||
icon = Icons.Rounded.PlayArrow,
|
||||
rows = listOf(
|
||||
rows = listOfNotNull(
|
||||
PlaybackSearchRow(
|
||||
"loading-overlay",
|
||||
stringResource(Res.string.settings_playback_show_loading_overlay),
|
||||
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(
|
||||
"hold-to-speed",
|
||||
stringResource(Res.string.settings_playback_hold_to_speed),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Download
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
|
|
@ -84,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.round
|
||||
|
|
@ -114,10 +116,20 @@ fun StreamsScreen(
|
|||
manualSelection: Boolean = false,
|
||||
startFromBeginning: Boolean = false,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
|
||||
onStreamActionOpen: (
|
||||
stream: StreamItem,
|
||||
openExternally: Boolean,
|
||||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
) -> Unit = { _, _, _, _ -> },
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val playerSettings by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by remember {
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
|
|
@ -323,6 +335,7 @@ fun StreamsScreen(
|
|||
|
||||
StreamActionsSheet(
|
||||
stream = streamActionsTarget,
|
||||
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
|
||||
onDismiss = { streamActionsTarget = null },
|
||||
onCopyLink = { stream ->
|
||||
val directUrl = stream.directPlaybackUrl
|
||||
|
|
@ -351,6 +364,14 @@ fun StreamsScreen(
|
|||
)
|
||||
NuvioToastController.show(result.toastMessage())
|
||||
},
|
||||
onOpen = { stream, openExternally ->
|
||||
onStreamActionOpen(
|
||||
stream,
|
||||
openExternally,
|
||||
effectiveResumePositionMs,
|
||||
effectiveResumeProgressFraction,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1008,9 +1029,11 @@ private fun StreamCard(
|
|||
@Composable
|
||||
private fun StreamActionsSheet(
|
||||
stream: StreamItem?,
|
||||
externalPlayerEnabled: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCopyLink: (StreamItem) -> Unit,
|
||||
onDownload: (StreamItem) -> Unit,
|
||||
onOpen: (StreamItem, openExternally: Boolean) -> Unit,
|
||||
) {
|
||||
if (stream == null) return
|
||||
|
||||
|
|
@ -1069,6 +1092,23 @@ private fun StreamActionsSheet(
|
|||
},
|
||||
)
|
||||
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(
|
||||
icon = Icons.Rounded.Download,
|
||||
title = stringResource(Res.string.streams_download_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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val resizeModeKey = "resize_mode"
|
||||
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
|
||||
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 secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
|
||||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
|
|
@ -57,6 +59,8 @@ actual object PlayerSettingsStorage {
|
|||
resizeModeKey,
|
||||
holdToSpeedEnabledKey,
|
||||
holdToSpeedValueKey,
|
||||
externalPlayerEnabledKey,
|
||||
externalPlayerIdKey,
|
||||
preferredAudioLanguageKey,
|
||||
secondaryPreferredAudioLanguageKey,
|
||||
preferredSubtitleLanguageKey,
|
||||
|
|
@ -140,6 +144,36 @@ actual object PlayerSettingsStorage {
|
|||
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? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(preferredAudioLanguageKey)
|
||||
|
|
@ -523,6 +557,8 @@ actual object PlayerSettingsStorage {
|
|||
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
|
||||
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(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)) }
|
||||
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
|
||||
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
|
|
@ -563,6 +599,8 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
|
||||
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
|
||||
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
|
||||
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
|
||||
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
|
||||
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
|
||||
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
|
||||
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@
|
|||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>infuse</string>
|
||||
<string>vlc-x-callback</string>
|
||||
<string>outplayer</string>
|
||||
<string>open-vidhub</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
|
|
|||
Loading…
Reference in a new issue