mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
Download subtitles locally before passing to external players
This commit is contained in:
parent
2c39f09a84
commit
becf580a49
12 changed files with 245 additions and 9 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,6 +3,9 @@
|
|||
<cache-path
|
||||
name="nuvio_updates"
|
||||
path="updates/" />
|
||||
<cache-path
|
||||
name="nuvio_subtitles"
|
||||
path="subtitles/" />
|
||||
<files-path
|
||||
name="nuvio_downloads"
|
||||
path="downloads/" />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -255,6 +255,9 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long,
|
|||
sourceHeaders = activeSourceHeaders,
|
||||
resumePositionMs = playbackSnapshot.positionMs,
|
||||
subtitles = loadedSubtitles,
|
||||
season = activeSeasonNumber,
|
||||
episode = activeEpisodeNumber,
|
||||
episodeTitle = activeEpisodeTitle,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>?
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -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(
|
||||
|
|
@ -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())
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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…
Reference in a new issue