mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite
This commit is contained in:
commit
0a3dd1d127
28 changed files with 2424 additions and 140 deletions
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="cs"/>
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="fr"/>
|
<locale android:name="fr"/>
|
||||||
<locale android:name="es"/>
|
|
||||||
<locale android:name="pt"/>
|
|
||||||
<locale android:name="tr"/>
|
|
||||||
<locale android:name="it"/>
|
|
||||||
<locale android:name="el"/>
|
|
||||||
<locale android:name="pl"/>
|
|
||||||
<locale android:name="de"/>
|
<locale android:name="de"/>
|
||||||
<locale android:name="cs"/>
|
<locale android:name="el"/>
|
||||||
|
<locale android:name="id"/>
|
||||||
|
<locale android:name="it"/>
|
||||||
|
<locale android:name="pl"/>
|
||||||
|
<locale android:name="pt"/>
|
||||||
|
<locale android:name="es"/>
|
||||||
|
<locale android:name="tr"/>
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -720,7 +720,7 @@
|
||||||
<string name="settings_playback_threshold_mode_percentage">Pourcentage</string>
|
<string name="settings_playback_threshold_mode_percentage">Pourcentage</string>
|
||||||
<string name="settings_playback_threshold_percentage">Pourcentage de seuil</string>
|
<string name="settings_playback_threshold_percentage">Pourcentage de seuil</string>
|
||||||
<string name="settings_playback_threshold_percentage_description">Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.</string>
|
<string name="settings_playback_threshold_percentage_description">Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.</string>
|
||||||
<string name="settings_playback_threshold_percentage_value">%1$s%</string>
|
<string name="settings_playback_threshold_percentage_value">%1$s %</string>
|
||||||
<string name="settings_playback_timeout_instant">Instantané</string>
|
<string name="settings_playback_timeout_instant">Instantané</string>
|
||||||
<string name="settings_playback_timeout_seconds">%1$ss</string>
|
<string name="settings_playback_timeout_seconds">%1$ss</string>
|
||||||
<string name="settings_playback_timeout_unlimited">Illimité</string>
|
<string name="settings_playback_timeout_unlimited">Illimité</string>
|
||||||
|
|
@ -1017,7 +1017,7 @@
|
||||||
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
|
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
|
||||||
<string name="streams_no_metadata">Aucune métadonnée disponible</string>
|
<string name="streams_no_metadata">Aucune métadonnée disponible</string>
|
||||||
<string name="streams_refresh">Actualiser les streams</string>
|
<string name="streams_refresh">Actualiser les streams</string>
|
||||||
<string name="streams_resume_from_percent">Reprendre depuis %1$d%</string>
|
<string name="streams_resume_from_percent">Reprendre depuis %1$d %</string>
|
||||||
<string name="streams_resume_from_time">Reprendre depuis %1$s</string>
|
<string name="streams_resume_from_time">Reprendre depuis %1$s</string>
|
||||||
<string name="streams_size">TAILLE %1$s</string>
|
<string name="streams_size">TAILLE %1$s</string>
|
||||||
<string name="trailer_close">Fermer la bande-annonce</string>
|
<string name="trailer_close">Fermer la bande-annonce</string>
|
||||||
|
|
@ -1027,7 +1027,7 @@
|
||||||
<string name="updates_asset_line">%1$s • %2$s</string>
|
<string name="updates_asset_line">%1$s • %2$s</string>
|
||||||
<string name="updates_check_failed">Échec de la vérification des mises à jour</string>
|
<string name="updates_check_failed">Échec de la vérification des mises à jour</string>
|
||||||
<string name="updates_download_failed">Échec du téléchargement</string>
|
<string name="updates_download_failed">Échec du téléchargement</string>
|
||||||
<string name="updates_downloading_progress">Téléchargement %1$d%%</string>
|
<string name="updates_downloading_progress">Téléchargement %1$d %</string>
|
||||||
<string name="updates_install_failed">Impossible de démarrer l'installation</string>
|
<string name="updates_install_failed">Impossible de démarrer l'installation</string>
|
||||||
<string name="updates_latest_version">Vous utilisez la version la plus récente.</string>
|
<string name="updates_latest_version">Vous utilisez la version la plus récente.</string>
|
||||||
<string name="updates_message_allow_installs">Activez l'installation d'applications pour Nuvio puis revenez pour continuer.</string>
|
<string name="updates_message_allow_installs">Activez l'installation d'applications pour Nuvio puis revenez pour continuer.</string>
|
||||||
|
|
|
||||||
1277
composeApp/src/commonMain/composeResources/values-id/strings.xml
Normal file
1277
composeApp/src/commonMain/composeResources/values-id/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -702,6 +702,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'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>
|
||||||
|
|
@ -1094,6 +1099,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>
|
||||||
|
|
@ -1114,6 +1121,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'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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -520,6 +531,7 @@ private fun MainAppContent(
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||||
|
var searchFocusRequestCount by remember { mutableStateOf(0) }
|
||||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||||
val liquidGlassNativeTabBarEnabled by remember {
|
val liquidGlassNativeTabBarEnabled by remember {
|
||||||
|
|
@ -562,6 +574,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) }
|
||||||
|
|
@ -583,6 +598,9 @@ private fun MainAppContent(
|
||||||
|
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
||||||
|
if (selectedTab != AppScreenTab.Search) {
|
||||||
|
searchFocusRequestCount = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(
|
DisposableEffect(
|
||||||
|
|
@ -752,6 +770,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 +824,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 +847,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
|
||||||
}
|
}
|
||||||
|
|
@ -1009,7 +1053,13 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
NavItem(
|
NavItem(
|
||||||
selected = selectedTab == AppScreenTab.Search,
|
selected = selectedTab == AppScreenTab.Search,
|
||||||
onClick = { selectedTab = AppScreenTab.Search },
|
onClick = {
|
||||||
|
if (selectedTab == AppScreenTab.Search) {
|
||||||
|
searchFocusRequestCount++
|
||||||
|
} else {
|
||||||
|
selectedTab = AppScreenTab.Search
|
||||||
|
}
|
||||||
|
},
|
||||||
icon = Res.drawable.sidebar_search,
|
icon = Res.drawable.sidebar_search,
|
||||||
contentDescription = stringResource(Res.string.compose_nav_search),
|
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||||
)
|
)
|
||||||
|
|
@ -1043,6 +1093,7 @@ private fun MainAppContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
searchFocusRequestCount = searchFocusRequestCount,
|
||||||
animateHomeCollectionGifs = tabsRouteActive,
|
animateHomeCollectionGifs = tabsRouteActive,
|
||||||
onCatalogClick = onCatalogClick,
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterClick = { meta ->
|
onPosterClick = { meta ->
|
||||||
|
|
@ -1097,7 +1148,13 @@ private fun MainAppContent(
|
||||||
if (isTabletLayout && !useNativeBottomTabs) {
|
if (isTabletLayout && !useNativeBottomTabs) {
|
||||||
TabletFloatingTopBar(
|
TabletFloatingTopBar(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { selectedTab = it },
|
onTabSelected = { tab ->
|
||||||
|
if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) {
|
||||||
|
searchFocusRequestCount++
|
||||||
|
} else {
|
||||||
|
selectedTab = tab
|
||||||
|
}
|
||||||
|
},
|
||||||
onProfileSelected = onProfileSelected,
|
onProfileSelected = onProfileSelected,
|
||||||
onAddProfileRequested = onSwitchProfile,
|
onAddProfileRequested = onSwitchProfile,
|
||||||
)
|
)
|
||||||
|
|
@ -1348,10 +1405,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 +1431,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 +1489,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 +1514,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,6 +1536,74 @@ private fun MainAppContent(
|
||||||
return@composable
|
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(
|
StreamsScreen(
|
||||||
type = launch.type,
|
type = launch.type,
|
||||||
videoId = effectiveVideoId,
|
videoId = effectiveVideoId,
|
||||||
|
|
@ -1490,62 +1622,22 @@ private fun MainAppContent(
|
||||||
manualSelection = launch.manualSelection,
|
manualSelection = launch.manualSelection,
|
||||||
startFromBeginning = launch.startFromBeginning,
|
startFromBeginning = launch.startFromBeginning,
|
||||||
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
||||||
val sourceUrl = stream.directPlaybackUrl
|
openSelectedStream(
|
||||||
if (sourceUrl != null) {
|
stream = stream,
|
||||||
// Persist for Reuse Last Link
|
resolvedResumePositionMs = resolvedResumePositionMs,
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
forceExternal = false,
|
||||||
type = launch.type,
|
forceInternal = false,
|
||||||
videoId = effectiveVideoId,
|
)
|
||||||
parentMetaId = launch.parentMetaId,
|
},
|
||||||
season = launch.seasonNumber,
|
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
|
||||||
episode = launch.episodeNumber,
|
openSelectedStream(
|
||||||
)
|
stream = stream,
|
||||||
StreamLinkCacheRepository.save(
|
resolvedResumePositionMs = resolvedResumePositionMs,
|
||||||
contentKey = cacheKey,
|
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
|
||||||
url = sourceUrl,
|
forceExternal = openExternally,
|
||||||
streamName = stream.streamLabel,
|
forceInternal = !openExternally,
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
StreamsRepository.clear()
|
StreamsRepository.clear()
|
||||||
|
|
@ -1674,8 +1766,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 +1788,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))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -2007,6 +2102,7 @@ private fun rememberGuardedPopBackStack(
|
||||||
private fun AppTabHost(
|
private fun AppTabHost(
|
||||||
selectedTab: AppScreenTab,
|
selectedTab: AppScreenTab,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
searchFocusRequestCount: Int = 0,
|
||||||
animateHomeCollectionGifs: Boolean = true,
|
animateHomeCollectionGifs: Boolean = true,
|
||||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
|
@ -2054,6 +2150,7 @@ private fun AppTabHost(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onPosterClick = onPosterClick,
|
onPosterClick = onPosterClick,
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
|
searchFocusRequestCount = searchFocusRequestCount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.contentColorFor
|
import androidx.compose.material3.contentColorFor
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
|
|
@ -37,11 +39,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
|
||||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
@ -51,6 +54,19 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): String =
|
||||||
|
when {
|
||||||
|
item.seasonNumber != null && item.episodeNumber != null && item.isNextUp ->
|
||||||
|
stringResource(Res.string.continue_watching_up_next_episode, item.seasonNumber, item.episodeNumber)
|
||||||
|
item.seasonNumber != null && item.episodeNumber != null ->
|
||||||
|
stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber)
|
||||||
|
item.isNextUp ->
|
||||||
|
stringResource(Res.string.continue_watching_up_next)
|
||||||
|
else ->
|
||||||
|
stringResource(Res.string.media_movie)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
||||||
useEpisodeThumbnails: Boolean,
|
useEpisodeThumbnails: Boolean,
|
||||||
): String? = when {
|
): String? = when {
|
||||||
|
|
@ -138,6 +154,11 @@ private fun HomeContinueWatchingSectionContent(
|
||||||
onItemClick: ((ContinueWatchingItem) -> Unit)?,
|
onItemClick: ((ContinueWatchingItem) -> Unit)?,
|
||||||
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
|
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val homeCatalogSettings by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = stringResource(Res.string.compose_settings_page_continue_watching),
|
title = stringResource(Res.string.compose_settings_page_continue_watching),
|
||||||
entries = items,
|
entries = items,
|
||||||
|
|
@ -145,6 +166,7 @@ private fun HomeContinueWatchingSectionContent(
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||||
itemSpacing = layout.itemGap,
|
itemSpacing = layout.itemGap,
|
||||||
|
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||||
key = { item -> item.videoId },
|
key = { item -> item.videoId },
|
||||||
) { item ->
|
) { item ->
|
||||||
when (style) {
|
when (style) {
|
||||||
|
|
@ -355,9 +377,8 @@ private fun ContinueWatchingWideCard(
|
||||||
.padding(layout.wideContentPadding),
|
.padding(layout.wideContentPadding),
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null
|
val wideMetaLine = localizedContinueWatchingMetaLine(item)
|
||||||
val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank()
|
val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val wideMetaLine = localizedContinueWatchingSubtitle(item)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -389,9 +410,9 @@ private fun ContinueWatchingWideCard(
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (hasEpisodeTitle) {
|
if (episodeTitle != null) {
|
||||||
Text(
|
Text(
|
||||||
text = item.episodeTitle.orEmpty(),
|
text = episodeTitle,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontSize = layout.wideMetaSize,
|
fontSize = layout.wideMetaSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -186,9 +186,18 @@ val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf(
|
||||||
LanguagePreferenceOption("zu", Res.string.lang_zulu),
|
LanguagePreferenceOption("zu", Res.string.lang_zulu),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val Iso639Aliases = mapOf(
|
private val LanguageCodeAliases = mapOf(
|
||||||
|
"pt-pt" to "pt",
|
||||||
|
"pt_br" to "pt-BR",
|
||||||
|
"pt-br" to "pt-BR",
|
||||||
|
"br" to "pt-BR",
|
||||||
|
"pob" to "pt-BR",
|
||||||
"eng" to "en",
|
"eng" to "en",
|
||||||
"spa" to "es",
|
"spa" to "es",
|
||||||
|
"es-419" to "es-419",
|
||||||
|
"es_419" to "es-419",
|
||||||
|
"es-la" to "es-419",
|
||||||
|
"es-lat" to "es-419",
|
||||||
"fra" to "fr",
|
"fra" to "fr",
|
||||||
"fre" to "fr",
|
"fre" to "fr",
|
||||||
"deu" to "de",
|
"deu" to "de",
|
||||||
|
|
@ -200,14 +209,170 @@ private val Iso639Aliases = mapOf(
|
||||||
"kor" to "ko",
|
"kor" to "ko",
|
||||||
"zho" to "zh",
|
"zho" to "zh",
|
||||||
"chi" to "zh",
|
"chi" to "zh",
|
||||||
|
"zht" to "zh-TW",
|
||||||
|
"zhs" to "zh-CN",
|
||||||
|
"chi-tw" to "zh-TW",
|
||||||
|
"chi-cn" to "zh-CN",
|
||||||
|
"zh-tw" to "zh-TW",
|
||||||
|
"zh_tw" to "zh-TW",
|
||||||
|
"zh-cn" to "zh-CN",
|
||||||
|
"zh_cn" to "zh-CN",
|
||||||
"ara" to "ar",
|
"ara" to "ar",
|
||||||
"hin" to "hi",
|
"hin" to "hi",
|
||||||
"nld" to "nl",
|
"nld" to "nl",
|
||||||
"dut" to "nl",
|
"dut" to "nl",
|
||||||
"pol" to "pl",
|
"pol" to "pl",
|
||||||
"swe" to "sv",
|
"swe" to "sv",
|
||||||
|
"nor" to "no",
|
||||||
|
"dan" to "da",
|
||||||
|
"fin" to "fi",
|
||||||
"tur" to "tr",
|
"tur" to "tr",
|
||||||
|
"ell" to "el",
|
||||||
|
"gre" to "el",
|
||||||
"heb" to "he",
|
"heb" to "he",
|
||||||
|
"tha" to "th",
|
||||||
|
"vie" to "vi",
|
||||||
|
"ind" to "id",
|
||||||
|
"msa" to "ms",
|
||||||
|
"may" to "ms",
|
||||||
|
"ces" to "cs",
|
||||||
|
"cze" to "cs",
|
||||||
|
"hun" to "hu",
|
||||||
|
"ron" to "ro",
|
||||||
|
"rum" to "ro",
|
||||||
|
"ukr" to "uk",
|
||||||
|
"bul" to "bg",
|
||||||
|
"hrv" to "hr",
|
||||||
|
"srp" to "sr",
|
||||||
|
"slk" to "sk",
|
||||||
|
"slo" to "sk",
|
||||||
|
"slv" to "sl",
|
||||||
|
"cat" to "ca",
|
||||||
|
"alb" to "sq",
|
||||||
|
"sqi" to "sq",
|
||||||
|
"bos" to "bs",
|
||||||
|
"mac" to "mk",
|
||||||
|
"mkd" to "mk",
|
||||||
|
"lav" to "lv",
|
||||||
|
"lit" to "lt",
|
||||||
|
"est" to "et",
|
||||||
|
"isl" to "is",
|
||||||
|
"ice" to "is",
|
||||||
|
"glg" to "gl",
|
||||||
|
"baq" to "eu",
|
||||||
|
"eus" to "eu",
|
||||||
|
"wel" to "cy",
|
||||||
|
"cym" to "cy",
|
||||||
|
"gle" to "ga",
|
||||||
|
"ben" to "bn",
|
||||||
|
"tam" to "ta",
|
||||||
|
"tel" to "te",
|
||||||
|
"mal" to "ml",
|
||||||
|
"kan" to "kn",
|
||||||
|
"mar" to "mr",
|
||||||
|
"pan" to "pa",
|
||||||
|
"guj" to "gu",
|
||||||
|
"urd" to "ur",
|
||||||
|
"fas" to "fa",
|
||||||
|
"per" to "fa",
|
||||||
|
"amh" to "am",
|
||||||
|
"swa" to "sw",
|
||||||
|
"zul" to "zu",
|
||||||
|
"afr" to "af",
|
||||||
|
"mlt" to "mt",
|
||||||
|
"bel" to "be",
|
||||||
|
"geo" to "ka",
|
||||||
|
"kat" to "ka",
|
||||||
|
"arm" to "hy",
|
||||||
|
"hye" to "hy",
|
||||||
|
"aze" to "az",
|
||||||
|
"kaz" to "kk",
|
||||||
|
"uzb" to "uz",
|
||||||
|
"mon" to "mn",
|
||||||
|
"khm" to "km",
|
||||||
|
"lao" to "lo",
|
||||||
|
"mya" to "my",
|
||||||
|
"bur" to "my",
|
||||||
|
"sin" to "si",
|
||||||
|
"nep" to "ne",
|
||||||
|
"tgl" to "tl",
|
||||||
|
"fil" to "tl",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LanguageNameAliases = mapOf(
|
||||||
|
"afrikaans" to "af",
|
||||||
|
"albanian" to "sq",
|
||||||
|
"amharic" to "am",
|
||||||
|
"arabic" to "ar",
|
||||||
|
"armenian" to "hy",
|
||||||
|
"azerbaijani" to "az",
|
||||||
|
"basque" to "eu",
|
||||||
|
"belarusian" to "be",
|
||||||
|
"bengali" to "bn",
|
||||||
|
"bosnian" to "bs",
|
||||||
|
"bulgarian" to "bg",
|
||||||
|
"burmese" to "my",
|
||||||
|
"catalan" to "ca",
|
||||||
|
"chinese" to "zh",
|
||||||
|
"mandarin" to "zh",
|
||||||
|
"croatian" to "hr",
|
||||||
|
"czech" to "cs",
|
||||||
|
"danish" to "da",
|
||||||
|
"dutch" to "nl",
|
||||||
|
"english" to "en",
|
||||||
|
"estonian" to "et",
|
||||||
|
"filipino" to "tl",
|
||||||
|
"finnish" to "fi",
|
||||||
|
"french" to "fr",
|
||||||
|
"galician" to "gl",
|
||||||
|
"georgian" to "ka",
|
||||||
|
"german" to "de",
|
||||||
|
"greek" to "el",
|
||||||
|
"gujarati" to "gu",
|
||||||
|
"hebrew" to "he",
|
||||||
|
"hindi" to "hi",
|
||||||
|
"hungarian" to "hu",
|
||||||
|
"icelandic" to "is",
|
||||||
|
"indonesian" to "id",
|
||||||
|
"irish" to "ga",
|
||||||
|
"italian" to "it",
|
||||||
|
"japanese" to "ja",
|
||||||
|
"kannada" to "kn",
|
||||||
|
"kazakh" to "kk",
|
||||||
|
"khmer" to "km",
|
||||||
|
"korean" to "ko",
|
||||||
|
"lao" to "lo",
|
||||||
|
"latvian" to "lv",
|
||||||
|
"lithuanian" to "lt",
|
||||||
|
"macedonian" to "mk",
|
||||||
|
"malay" to "ms",
|
||||||
|
"malayalam" to "ml",
|
||||||
|
"maltese" to "mt",
|
||||||
|
"marathi" to "mr",
|
||||||
|
"mongolian" to "mn",
|
||||||
|
"nepali" to "ne",
|
||||||
|
"norwegian" to "no",
|
||||||
|
"persian" to "fa",
|
||||||
|
"polish" to "pl",
|
||||||
|
"punjabi" to "pa",
|
||||||
|
"romanian" to "ro",
|
||||||
|
"russian" to "ru",
|
||||||
|
"serbian" to "sr",
|
||||||
|
"sinhala" to "si",
|
||||||
|
"slovak" to "sk",
|
||||||
|
"slovenian" to "sl",
|
||||||
|
"swahili" to "sw",
|
||||||
|
"swedish" to "sv",
|
||||||
|
"tamil" to "ta",
|
||||||
|
"telugu" to "te",
|
||||||
|
"thai" to "th",
|
||||||
|
"turkish" to "tr",
|
||||||
|
"ukrainian" to "uk",
|
||||||
|
"urdu" to "ur",
|
||||||
|
"uzbek" to "uz",
|
||||||
|
"vietnamese" to "vi",
|
||||||
|
"welsh" to "cy",
|
||||||
|
"zulu" to "zu",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun normalizeLanguageCode(language: String?): String? {
|
fun normalizeLanguageCode(language: String?): String? {
|
||||||
|
|
@ -218,13 +383,55 @@ fun normalizeLanguageCode(language: String?): String? {
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
|
val tokenized = raw
|
||||||
|
.replace('-', ' ')
|
||||||
|
.replace('.', ' ')
|
||||||
|
.replace('/', ' ')
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
fun containsAny(vararg values: String): Boolean =
|
||||||
|
values.any { value -> tokenized.contains(value) }
|
||||||
|
|
||||||
|
if (containsAny("portuguese", "portugues")) {
|
||||||
|
return when {
|
||||||
|
containsAny("brazil", "brasil", "brazilian", "brasileiro", "pt br", "ptbr", "pob", "(br)") ->
|
||||||
|
"pt-br"
|
||||||
|
containsAny("portugal", "european", "europeu", "iberian", "pt pt", "ptpt") ->
|
||||||
|
"pt"
|
||||||
|
else -> "pt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsAny("spanish", "espanol", "castellano")) {
|
||||||
|
return if (containsAny("latin", "latino", "latinoamerica", "latinoamericano", "lat am", "latam", "es 419", "es419", "(419)")) {
|
||||||
|
"es-419"
|
||||||
|
} else {
|
||||||
|
"es"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LanguageCodeAliases[raw]?.let { return it.replace('_', '-').lowercase() }
|
||||||
|
LanguageNameAliases[tokenized]?.let { return it }
|
||||||
|
LanguageNameAliases.entries
|
||||||
|
.sortedByDescending { it.key.length }
|
||||||
|
.firstOrNull { (name, _) ->
|
||||||
|
tokenized == name ||
|
||||||
|
tokenized.startsWith("$name ") ||
|
||||||
|
tokenized.endsWith(" $name") ||
|
||||||
|
tokenized.contains(" $name ")
|
||||||
|
}
|
||||||
|
?.let { return it.value }
|
||||||
|
|
||||||
val primary = raw.substringBefore('-')
|
val primary = raw.substringBefore('-')
|
||||||
val canonicalPrimary = Iso639Aliases[primary] ?: primary
|
val primaryAlias = LanguageCodeAliases[primary]?.replace('_', '-')?.lowercase()
|
||||||
val suffix = raw.substringAfter('-', "")
|
val suffix = raw.substringAfter('-', "")
|
||||||
return if (suffix.isBlank()) {
|
return if (suffix.isBlank()) {
|
||||||
canonicalPrimary
|
primaryAlias ?: primary
|
||||||
|
} else if (primaryAlias != null && !primaryAlias.contains('-')) {
|
||||||
|
"$primaryAlias-$suffix"
|
||||||
} else {
|
} else {
|
||||||
"$canonicalPrimary-$suffix"
|
primaryAlias ?: "$primary-$suffix"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.AddonResource
|
||||||
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
|
|
@ -439,8 +441,24 @@ fun PlayerScreen(
|
||||||
var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) }
|
var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) }
|
||||||
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
|
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
|
||||||
val subtitleStyle = playerSettingsUiState.subtitleStyle
|
val subtitleStyle = playerSettingsUiState.subtitleStyle
|
||||||
|
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
|
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
|
||||||
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
|
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
|
||||||
|
val activeAddonSubtitleType = contentType ?: parentMetaType
|
||||||
|
val addonSubtitleFetchKey = remember(
|
||||||
|
addonsUiState.addons,
|
||||||
|
activeAddonSubtitleType,
|
||||||
|
activeVideoId,
|
||||||
|
) {
|
||||||
|
buildAddonSubtitleFetchKey(
|
||||||
|
addons = addonsUiState.addons,
|
||||||
|
type = activeAddonSubtitleType,
|
||||||
|
videoId = activeVideoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) {
|
||||||
|
mutableStateOf<String?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshTracks() {
|
fun refreshTracks() {
|
||||||
val ctrl = playerController ?: return
|
val ctrl = playerController ?: return
|
||||||
|
|
@ -1092,8 +1110,8 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchAddonSubtitlesForActiveItem() {
|
fun fetchAddonSubtitlesForActiveItem() {
|
||||||
val type = contentType ?: return
|
val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
|
||||||
val videoId = activeVideoId ?: return
|
val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
|
||||||
SubtitleRepository.fetchAddonSubtitles(type, videoId)
|
SubtitleRepository.fetchAddonSubtitles(type, videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1127,11 +1145,11 @@ fun PlayerScreen(
|
||||||
playerController?.applySubtitleStyle(subtitleStyle)
|
playerController?.applySubtitleStyle(subtitleStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) {
|
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
|
||||||
if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect
|
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
|
||||||
if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) {
|
if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
|
||||||
fetchAddonSubtitlesForActiveItem()
|
autoFetchedAddonSubtitlesForKey = fetchKey
|
||||||
}
|
fetchAddonSubtitlesForActiveItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
|
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
|
||||||
|
|
@ -1924,6 +1942,47 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildAddonSubtitleFetchKey(
|
||||||
|
addons: List<ManagedAddon>,
|
||||||
|
type: String?,
|
||||||
|
videoId: String?,
|
||||||
|
): String? {
|
||||||
|
val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val compatibleSubtitleAddons = addons.mapNotNull { addon ->
|
||||||
|
val manifest = addon.manifest ?: return@mapNotNull null
|
||||||
|
val supportsSubtitles = manifest.resources.any { resource ->
|
||||||
|
resource.isCompatibleSubtitleResource(
|
||||||
|
type = normalizedType,
|
||||||
|
videoId = normalizedVideoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!supportsSubtitles) return@mapNotNull null
|
||||||
|
"${manifest.id}:${manifest.transportUrl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compatibleSubtitleAddons.isEmpty()) return null
|
||||||
|
return buildString {
|
||||||
|
append(normalizedType)
|
||||||
|
append('|')
|
||||||
|
append(normalizedVideoId)
|
||||||
|
append('|')
|
||||||
|
append(compatibleSubtitleAddons.sorted().joinToString("|"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean {
|
||||||
|
val isSubtitleResource = name.equals("subtitles", ignoreCase = true) ||
|
||||||
|
name.equals("subtitle", ignoreCase = true)
|
||||||
|
if (!isSubtitleResource) return false
|
||||||
|
|
||||||
|
val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type
|
||||||
|
val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) }
|
||||||
|
if (!typeMatches) return false
|
||||||
|
|
||||||
|
return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> findPreferredTrackIndex(
|
private fun <T> findPreferredTrackIndex(
|
||||||
tracks: List<T>,
|
tracks: List<T>,
|
||||||
targets: List<String>,
|
targets: List<String>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package com.nuvio.app.features.player
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.addons.AddonResource
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -15,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
@ -35,8 +39,12 @@ object SubtitleRepository {
|
||||||
private val _error = MutableStateFlow<String?>(null)
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
val error: StateFlow<String?> = _error.asStateFlow()
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
private var activeFetchJob: Job? = null
|
||||||
|
|
||||||
fun fetchAddonSubtitles(type: String, videoId: String) {
|
fun fetchAddonSubtitles(type: String, videoId: String) {
|
||||||
scope.launch {
|
activeFetchJob?.cancel()
|
||||||
|
activeFetchJob = scope.launch {
|
||||||
|
val requestType = canonicalSubtitleType(type)
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
_error.value = null
|
_error.value = null
|
||||||
_addonSubtitles.value = emptyList()
|
_addonSubtitles.value = emptyList()
|
||||||
|
|
@ -46,17 +54,13 @@ object SubtitleRepository {
|
||||||
|
|
||||||
for (addon in addons) {
|
for (addon in addons) {
|
||||||
val manifest = addon.manifest ?: continue
|
val manifest = addon.manifest ?: continue
|
||||||
val subtitleResource = manifest.resources.find { it.name == "subtitles" } ?: continue
|
val subtitleResource = manifest.resources.find { it.name.isSubtitleResourceName() } ?: continue
|
||||||
if (!subtitleResource.types.contains(type)) continue
|
if (!subtitleResource.supportsSubtitleType(requestType, videoId)) continue
|
||||||
|
|
||||||
val prefixMatch = subtitleResource.idPrefixes.isEmpty() ||
|
|
||||||
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
|
|
||||||
if (!prefixMatch) continue
|
|
||||||
|
|
||||||
val subtitleUrl = buildAddonResourceUrl(
|
val subtitleUrl = buildAddonResourceUrl(
|
||||||
manifestUrl = manifest.transportUrl,
|
manifestUrl = manifest.transportUrl,
|
||||||
resource = "subtitles",
|
resource = "subtitles",
|
||||||
type = type,
|
type = requestType,
|
||||||
id = videoId,
|
id = videoId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -69,21 +73,23 @@ object SubtitleRepository {
|
||||||
|
|
||||||
for (element in subtitlesArray) {
|
for (element in subtitlesArray) {
|
||||||
val obj = element.jsonObject
|
val obj = element.jsonObject
|
||||||
val id = obj["id"]?.jsonPrimitive?.content
|
val id = obj.stringValue("id")
|
||||||
?: "${manifest.id}_${allSubs.size}"
|
?: "${manifest.id}_${allSubs.size}"
|
||||||
val url = obj["url"]?.jsonPrimitive?.content ?: continue
|
val url = obj.stringValue("url") ?: continue
|
||||||
val lang = obj["lang"]?.jsonPrimitive?.content ?: "unknown"
|
val rawLang = obj.subtitleLanguage() ?: "unknown"
|
||||||
|
val normalizedLang = normalizeLanguageCode(rawLang) ?: rawLang
|
||||||
|
|
||||||
allSubs.add(
|
allSubs.add(
|
||||||
AddonSubtitle(
|
AddonSubtitle(
|
||||||
id = id,
|
id = id,
|
||||||
url = url,
|
url = url,
|
||||||
language = lang,
|
language = normalizedLang,
|
||||||
display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})",
|
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,8 +102,35 @@ object SubtitleRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
activeFetchJob?.cancel()
|
||||||
_addonSubtitles.value = emptyList()
|
_addonSubtitles.value = emptyList()
|
||||||
_isLoading.value = false
|
_isLoading.value = false
|
||||||
_error.value = null
|
_error.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun canonicalSubtitleType(type: String): String =
|
||||||
|
if (type.equals("tv", ignoreCase = true)) "series" else type.lowercase()
|
||||||
|
|
||||||
|
private fun String.isSubtitleResourceName(): Boolean =
|
||||||
|
equals("subtitles", ignoreCase = true) || equals("subtitle", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun AddonResource.supportsSubtitleType(type: String, videoId: String): Boolean {
|
||||||
|
val typeMatches = types.isEmpty() || types.any { it.equals(type, ignoreCase = true) }
|
||||||
|
if (!typeMatches) return false
|
||||||
|
return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.subtitleLanguage(): String? =
|
||||||
|
stringValue("lang")
|
||||||
|
?: stringValue("language")
|
||||||
|
?: stringValue("languageCode")
|
||||||
|
?: stringValue("locale")
|
||||||
|
?: stringValue("label")
|
||||||
|
|
||||||
|
private fun JsonObject.stringValue(name: String): String? =
|
||||||
|
this[name]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -80,7 +82,16 @@ fun SearchScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
searchFocusRequestCount: Int = 0,
|
||||||
) {
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(searchFocusRequestCount) {
|
||||||
|
if (searchFocusRequestCount > 0) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
AddonRepository.initialize()
|
AddonRepository.initialize()
|
||||||
WatchedRepository.ensureLoaded()
|
WatchedRepository.ensureLoaded()
|
||||||
|
|
@ -240,6 +251,7 @@ fun SearchScreen(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = { query = it },
|
onValueChange = { query = it },
|
||||||
placeholder = stringResource(Res.string.compose_search_placeholder),
|
placeholder = stringResource(Res.string.compose_search_placeholder),
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
trailingContent = if (query.isNotBlank()) {
|
trailingContent = if (query.isNotBlank()) {
|
||||||
{
|
{
|
||||||
IconButton(onClick = { query = "" }) {
|
IconButton(onClick = { query = "" }) {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,34 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.lang_czech
|
||||||
import nuvio.composeapp.generated.resources.lang_english
|
import nuvio.composeapp.generated.resources.lang_english
|
||||||
import nuvio.composeapp.generated.resources.lang_french
|
import nuvio.composeapp.generated.resources.lang_french
|
||||||
import nuvio.composeapp.generated.resources.lang_german
|
import nuvio.composeapp.generated.resources.lang_german
|
||||||
import nuvio.composeapp.generated.resources.lang_spanish
|
|
||||||
import nuvio.composeapp.generated.resources.lang_portuguese_portugal
|
|
||||||
import nuvio.composeapp.generated.resources.lang_turkish
|
|
||||||
import nuvio.composeapp.generated.resources.lang_italian
|
|
||||||
import nuvio.composeapp.generated.resources.lang_greek
|
import nuvio.composeapp.generated.resources.lang_greek
|
||||||
|
import nuvio.composeapp.generated.resources.lang_indonesian
|
||||||
|
import nuvio.composeapp.generated.resources.lang_italian
|
||||||
import nuvio.composeapp.generated.resources.lang_polish
|
import nuvio.composeapp.generated.resources.lang_polish
|
||||||
import nuvio.composeapp.generated.resources.lang_czech
|
import nuvio.composeapp.generated.resources.lang_portuguese_portugal
|
||||||
|
import nuvio.composeapp.generated.resources.lang_spanish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_turkish
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
|
||||||
enum class AppLanguage(
|
enum class AppLanguage(
|
||||||
val code: String,
|
val code: String,
|
||||||
val labelRes: StringResource,
|
val labelRes: StringResource,
|
||||||
) {
|
) {
|
||||||
|
CZECH("cs", Res.string.lang_czech),
|
||||||
ENGLISH("en", Res.string.lang_english),
|
ENGLISH("en", Res.string.lang_english),
|
||||||
FRENCH("fr", Res.string.lang_french),
|
FRENCH("fr", Res.string.lang_french),
|
||||||
GERMAN("de", Res.string.lang_german),
|
GERMAN("de", Res.string.lang_german),
|
||||||
SPANISH("es", Res.string.lang_spanish),
|
|
||||||
PORTUGUESE("pt", Res.string.lang_portuguese_portugal),
|
|
||||||
TURKISH("tr", Res.string.lang_turkish),
|
|
||||||
ITALIAN("it", Res.string.lang_italian),
|
|
||||||
GREEK("el", Res.string.lang_greek),
|
GREEK("el", Res.string.lang_greek),
|
||||||
|
INDONESIAN("id", Res.string.lang_indonesian),
|
||||||
|
ITALIAN("it", Res.string.lang_italian),
|
||||||
POLISH("pl", Res.string.lang_polish),
|
POLISH("pl", Res.string.lang_polish),
|
||||||
CZECH("cs", Res.string.lang_czech),
|
PORTUGUESE("pt", Res.string.lang_portuguese_portugal),
|
||||||
|
SPANISH("es", Res.string.lang_spanish),
|
||||||
|
TURKISH("tr", Res.string.lang_turkish),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ androidx-lifecycle = "2.11.0-alpha03"
|
||||||
androidx-work = "2.10.3"
|
androidx-work = "2.10.3"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
composeMultiplatform = "1.11.0-beta03"
|
composeMultiplatform = "1.11.0-beta03"
|
||||||
coil = "3.4.0"
|
coil = "3.5.0-beta01"
|
||||||
kermit = "2.0.5"
|
kermit = "2.0.5"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=58
|
CURRENT_PROJECT_VERSION=59
|
||||||
MARKETING_VERSION=0.1.0
|
MARKETING_VERSION=0.1.19
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue