Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.SubtitleFileCache
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.p2p.P2pSettingsStorage
import com.nuvio.app.features.p2p.P2pStreamingEngine
Expand Down Expand Up @@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() {
P2pSettingsStorage.initialize(applicationContext)
P2pStreamingEngine.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
SubtitleFileCache.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform {
}

// Title extras
val displayTitle = request.streamTitle ?: request.title
val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true)
putExtra(Intent.EXTRA_TITLE, displayTitle)
putExtra("title", displayTitle)
putExtra("forcename", displayTitle) // Vimu Player
Expand All @@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform {
if (request.resumePositionMs > 0L) {
putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv
putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player
putExtra("forceresume", true) // Vimu: enable resume for network streams
putExtra("from_start", false) // VLC: don't force start from beginning
}

Expand All @@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform {
val subtitleNames = subtitles.map { it.name }.toTypedArray()
val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray()

// Grant read permission for content:// URIs via ClipData.
// FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras.
// Adding all subtitle URIs to ClipData ensures the receiving player
// gets read access to all of them.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val clipData = android.content.ClipData(
"subtitles",
arrayOf("application/x-subrip", "text/vtt"),
android.content.ClipData.Item(subtitleUris.first())
)
subtitleUris.drop(1).forEach { subtitleUri ->
clipData.addItem(android.content.ClipData.Item(subtitleUri))
}
setClipData(clipData)

// MX Player / mpv-android / Nova
putExtra("subs", subtitleUris)
putExtra("subs.name", subtitleNames)
putExtra("subs.filename", subtitleFilenames)
putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url)))
putExtra("subs.enable", arrayOf(subtitleUris.first()))

// Just Player
putExtra("subtitle_uri", subtitleUris)
putExtra("subtitle_name", subtitleNames)

// VLC (single subtitle — use first one)
putExtra("subtitles_location", Uri.parse(subtitles.first().url))
putExtra("subtitles_location", subtitleUris.first())

// Vimu Player
putExtra("forcedsrt", subtitles.first().url)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.nuvio.app.features.player

/**
* Android implementation: downloads subtitles to local cache and returns content:// URIs
* via FileProvider so external players can read them.
*/
actual object SubtitleCacheProvider {
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
return SubtitleFileCache.cacheSubtitles(subtitles)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.nuvio.app.features.player

import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File

/**
* Downloads subtitle files from remote URLs to local cache and provides
* content:// URIs via FileProvider so external players can read them.
*/
object SubtitleFileCache {
private const val TAG = "SubtitleFileCache"
private const val SUBTITLE_CACHE_DIR = "subtitles"

private var appContext: Context? = null
private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }

fun initialize(context: Context) {
appContext = context.applicationContext
}

private val cacheDir: File?
get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } }

/**
* Downloads subtitle files and returns updated [SubtitleInput] list with
* content:// URIs instead of HTTP URLs.
*
* Subtitles that fail to download are silently skipped (not included in result).
* Returns null if no subtitles could be downloaded.
*/
suspend fun cacheSubtitles(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
if (subtitles.isEmpty()) return null
val context = appContext ?: return null

// Clean old cached subtitles before downloading new ones
clearCache()

val cached = subtitles.mapNotNull { input ->
try {
val localUri = downloadToCache(context, input)
if (localUri != null) {
input.copy(url = localUri.toString())
} else {
Log.w(TAG, "Failed to download subtitle: ${input.name}")
null
}
} catch (e: Exception) {
Log.w(TAG, "Error caching subtitle: ${input.name}", e)
null
}
}

return cached.ifEmpty { null }
}

/**
* Downloads a single subtitle file and returns a content:// URI.
*/
private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? =
withContext(Dispatchers.IO) {
val dir = cacheDir ?: return@withContext null
val extension = guessExtension(input.url)
val filename = sanitizeFilename("${input.lang}_${input.name}.$extension")
val file = File(dir, filename)

val request = Request.Builder()
.url(input.url)
.build()

try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}")
return@withContext null
}

val body = response.body ?: return@withContext null
file.outputStream().use { output ->
body.byteStream().copyTo(output)
}
}

// Return content:// URI via FileProvider
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
} catch (e: Exception) {
Log.w(TAG, "Failed to download subtitle file: ${input.url}", e)
file.delete()
null
}
}

/**
* Removes all cached subtitle files.
*/
fun clearCache() {
try {
cacheDir?.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
Log.w(TAG, "Failed to clear subtitle cache", e)
}
}

private fun guessExtension(url: String): String {
val path = url.substringBefore('?').substringBefore('#').trimEnd('/')
return when {
path.endsWith(".vtt", ignoreCase = true) -> "vtt"
path.endsWith(".ass", ignoreCase = true) -> "ass"
path.endsWith(".ssa", ignoreCase = true) -> "ssa"
path.endsWith(".ttml", ignoreCase = true) -> "ttml"
path.endsWith(".dfxp", ignoreCase = true) -> "dfxp"
else -> "srt"
}
}

private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_")
.take(100) // Limit filename length
}
}
3 changes: 3 additions & 0 deletions composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<cache-path
name="nuvio_updates"
path="updates/" />
<cache-path
name="nuvio_subtitles"
path="subtitles/" />
<files-path
name="nuvio_downloads"
path="downloads/" />
Expand Down
3 changes: 3 additions & 0 deletions composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba
streamTitle = streamTitle,
sourceHeaders = sourceHeaders,
resumePositionMs = initialPositionMs,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)

private enum class AppGateScreen {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package com.nuvio.app.features.player

/**
* Orchestrates the full external player launch flow:
* fetches subtitles if forwarding is enabled, then returns an enriched request
* for the caller to dispatch.
* fetches subtitles if forwarding is enabled, downloads them to local cache,
* then returns an enriched request for the caller to dispatch.
*/
suspend fun prepareExternalPlayerLaunch(
request: ExternalPlayerPlaybackRequest,
Expand All @@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch(
)

if (subtitles != null) {
onOverlayMessage("Downloading subtitles...")
val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles)
if (cachedSubtitles != null) {
return request.copy(subtitles = cachedSubtitles)
}
// Fallback: use original URLs if caching fails
return request.copy(subtitles = subtitles)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest(
val sourceHeaders: Map<String, String> = emptyMap(),
val resumePositionMs: Long = 0L,
val subtitles: List<SubtitleInput>? = null,
)
val season: Int? = null,
val episode: Int? = null,
val episodeTitle: String? = null,
) {
/**
* Builds a display title for external players.
* For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title"
* For movies: just the content name (title).
*/
fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String {
if (season == null || episode == null) return title
val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) {
"$title - $seasonEp - $episodeTitle"
} else {
"$title - $seasonEp"
}
}
}

enum class ExternalPlayerOpenResult {
Opened,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long,
sourceHeaders = activeSourceHeaders,
resumePositionMs = playbackSnapshot.positionMs,
subtitles = loadedSubtitles,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.nuvio.app.features.player

/**
* Platform-specific subtitle caching for external players.
*
* On Android, downloads subtitle files from remote URLs to local cache and returns
* content:// URIs so external players can access them (via intent extras + ClipData).
*
* On iOS, returns subtitles unchanged — players like Infuse accept remote URLs
* directly via their URL scheme parameters.
*/
expect object SubtitleCacheProvider {
/**
* Caches subtitle files locally and returns updated [SubtitleInput] list
* with local URIs instead of remote HTTP URLs.
*
* Returns null if caching fails or no subtitles could be downloaded.
* On platforms where caching is not needed, returns the input list unchanged.
*/
suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>?
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ private val iosExternalPlayerSpecs = listOf(
append("infuse://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode())
request.subtitles?.forEach { subtitle ->
append("&sub=")
append(subtitle.url.urlQueryEncode())
}
}
},
),
Expand All @@ -29,7 +33,14 @@ private val iosExternalPlayerSpecs = listOf(
name = "VLC",
scheme = "vlc-x-callback",
buildUrl = { request ->
"vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}"
buildString {
append("vlc-x-callback://x-callback-url/stream?url=")
append(request.sourceUrl.urlQueryEncode())
request.subtitles?.firstOrNull()?.let { subtitle ->
append("&sub=")
append(subtitle.url.urlQueryEncode())
}
}
},
),
IosExternalPlayerSpec(
Expand All @@ -41,7 +52,7 @@ private val iosExternalPlayerSpecs = listOf(
append("outplayer://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode())
}
},
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nuvio.app.features.player

/**
* iOS implementation: returns subtitles unchanged (no caching needed).
* iOS players like Infuse accept remote subtitle URLs directly via their URL scheme,
* so we just pass through the original HTTP URLs without downloading.
*/
actual object SubtitleCacheProvider {
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
return subtitles.ifEmpty { null }
}
}
Loading