diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 4f0136540..ad83b451b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 19bb30218..2afd14d52 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt new file mode 100644 index 000000000..1fc7223af --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt @@ -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): List? { + return SubtitleFileCache.cacheSubtitles(subtitles) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt new file mode 100644 index 000000000..3fa7106da --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt @@ -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): List? { + 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 + } +} diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml index 9759b2796..e9db651dc 100644 --- a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml +++ b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml @@ -3,6 +3,9 @@ + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 32b14c525..c652c6ba8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt index 7c79566a6..511241974 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt @@ -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) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index b7e4d5c91..60bfd3f41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest( val sourceHeaders: Map = emptyMap(), val resumePositionMs: Long = 0L, val subtitles: List? = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index fbfc91d02..3119c79f1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -255,6 +255,9 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, sourceHeaders = activeSourceHeaders, resumePositionMs = playbackSnapshot.positionMs, subtitles = loadedSubtitles, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt new file mode 100644 index 000000000..be4c88fd1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt @@ -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): List? +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt index 9fe3cb6d5..c579e01b6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt @@ -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()) } }, ), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt new file mode 100644 index 000000000..dc2e47a63 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt @@ -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): List? { + return subtitles.ifEmpty { null } + } +}