diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 62ebd521..c44ac002 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -35,6 +35,9 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -68,6 +71,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -146,12 +150,26 @@ actual fun PlatformPlayerSurface( .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE) - val dataSourceFactory = PlatformPlaybackDataSourceFactory.create( - context = context, - defaultRequestHeaders = sanitizedSourceHeaders, + val baseNetworkFactory = if (useYoutubeChunkedPlayback) { + YoutubeChunkedDataSourceFactory(defaultRequestHeaders = sanitizedSourceHeaders) + } else { + PlayerPlaybackNetworking.createHttpDataSourceFactory(sanitizedSourceHeaders) + } + + val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory( + upstreamFactory = baseNetworkFactory, + externalSubtitles = externalSubtitles + ) + + val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory) + val dataSourceFactory = if (sanitizedSourceResponseHeaders.isEmpty()) { + baseFactory + } else { + ResponseHeaderOverridingDataSourceFactory( + upstreamFactory = baseFactory, defaultResponseHeaders = sanitizedSourceResponseHeaders, - useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, ) + } val player = if (useLibass) { ExoPlayer.Builder(context) @@ -179,13 +197,33 @@ actual fun PlatformPlayerSurface( } player.apply { + val mediaItemBuilder = MediaItem.Builder() + .setUri(Uri.parse(sourceUrl)) + .setMediaId(sourceUrl) + + val subtitleConfigs = externalSubtitles.mapNotNull { subtitle -> + val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers) + MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url)) + .setMimeType(mimeType) + .setLanguage(subtitle.language) + .setLabel(subtitle.name ?: subtitle.language) + .setRoleFlags(C.ROLE_FLAG_SUBTITLE) + .build() + } + + if (subtitleConfigs.isNotEmpty()) { + mediaItemBuilder.setSubtitleConfigurations(subtitleConfigs) + } + + val mediaItem = mediaItemBuilder.build() + if (!sourceAudioUrl.isNullOrBlank()) { val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) - val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl)) + val videoSource = msf.createMediaSource(mediaItem) val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl)) setMediaSource(MergingMediaSource(videoSource, audioSource)) } else { - setMediaItem(MediaItem.fromUri(sourceUrl)) + setMediaItem(mediaItem) } fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) } prepare() @@ -709,15 +747,15 @@ private fun ExoPlayer.logCurrentTracks(context: String) { Log.d(TAG, "--- end logCurrentTracks ---") } -private fun resolveSubtitleMimeType(url: String): String { - probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) -> +private fun resolveSubtitleMimeType(url: String, headers: Map? = null): String { + probeSubtitleHeaders(url, headers)?.let { (contentType, contentDisposition) -> mapSubtitleMime(contentType)?.let { return it } filenameFromContentDisposition(contentDisposition)?.let(::guessSubtitleMime)?.let { return it } } return guessSubtitleMime(url) } -private fun probeSubtitleHeaders(url: String): Pair? { +private fun probeSubtitleHeaders(url: String, headers: Map? = null): Pair? { val methods = listOf("HEAD", "GET") methods.forEach { method -> runCatching { @@ -727,6 +765,9 @@ private fun probeSubtitleHeaders(url: String): Pair? { readTimeout = 5_000 instanceFollowRedirects = true setRequestProperty("Accept", "*/*") + headers?.forEach { (key, value) -> + setRequestProperty(key, value) + } } try { connection.responseCode @@ -781,3 +822,50 @@ private fun guessSubtitleMime(url: String): String { else -> MimeTypes.TEXT_VTT } } + +private class SubtitleRequestHeaderDataSourceFactory( + private val upstreamFactory: DataSource.Factory, + private val externalSubtitles: List, +) : DataSource.Factory { + override fun createDataSource(): DataSource = + SubtitleRequestHeaderDataSource( + upstream = upstreamFactory.createDataSource(), + externalSubtitles = externalSubtitles, + ) +} + +private class SubtitleRequestHeaderDataSource( + private val upstream: DataSource, + private val externalSubtitles: List, +) : DataSource { + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val url = dataSpec.uri.toString() + val subtitle = externalSubtitles.find { it.url == url } + val headers = subtitle?.headers + + return if (headers.isNullOrEmpty()) { + upstream.open(dataSpec) + } else { + val mergedHeaders = dataSpec.httpRequestHeaders.toMutableMap() + headers.forEach { (key, value) -> + mergedHeaders[key] = value + } + upstream.open(dataSpec.buildUpon().setHttpRequestHeaders(mergedHeaders).build()) + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> = upstream.responseHeaders + + override fun close() { + upstream.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..3ee87ef0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -872,6 +872,7 @@ private fun MainAppContent( sourceUrl = localSourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = logo, poster = poster, background = background, @@ -1457,6 +1458,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + externalSubtitles = emptyList(), logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1563,6 +1565,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1582,8 +1585,7 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, - ) - StreamsRepository.consumeAutoPlay() + ) StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) @@ -1673,6 +1675,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1803,6 +1806,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + externalSubtitles = launch.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1908,6 +1912,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = item.logo, poster = item.poster, background = item.background, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ac0be69f..87773ddc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -56,6 +56,7 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 773a276d..e43c0df6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -13,6 +13,7 @@ data class PlayerLaunch( val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val externalSubtitles: List = emptyList(), val logo: String? = null, val poster: String? = null, val background: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 476b0a77..d3f4483e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -126,6 +126,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), providerName: String, streamTitle: String, streamSubtitle: String?, @@ -144,6 +145,8 @@ fun PlayerScreen( videoId: String? = null, parentMetaId: String, parentMetaType: String, + parentMetaLogo: String? = null, + parentMetaPoster: String? = null, providerAddonId: String? = null, initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, @@ -1713,6 +1716,7 @@ fun PlayerScreen( sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, + externalSubtitles = externalSubtitles, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 013460c3..ff990846 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -401,5 +401,13 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + com.nuvio.app.features.streams.StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index e069a5fb..98769a63 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -78,6 +78,15 @@ data class PluginRuntimeResult( val peers: Int? = null, val infoHash: String? = null, val headers: Map? = null, + val subtitles: List? = null, +) + +@Serializable +data class PluginSubtitleResult( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null ) data class PluginsUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0b3d8b24..67682bc3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -4,6 +4,14 @@ import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString +@Serializable +data class StreamSubtitle( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null +) + data class StreamItem( val name: String? = null, val title: String? = null, @@ -18,6 +26,7 @@ data class StreamItem( val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, + val externalSubtitles: List = emptyList(), ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 2fc87a24..a6787f53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -665,6 +665,14 @@ private fun PluginRuntimeResult.toStreamItem( proxyHeaders = StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt index 4128f45c..f0033b18 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt @@ -11,6 +11,7 @@ class PlayerLaunchStoreTest { val launch = PlayerLaunch( title = "Title", sourceUrl = "https://example.com/video.m3u8?token=a/b:c", + externalSubtitles = emptyList(), streamTitle = "Source", providerName = "Provider", parentMetaId = "tt1234567", diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 5c884568..3fe3e728 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -206,6 +206,25 @@ internal object PluginRuntime { ?.toMap() ?.takeIf { it.isNotEmpty() } + val subtitles = (item["subtitles"] as? JsonArray)?.mapNotNull { subElement -> + val subObj = subElement as? JsonObject ?: return@mapNotNull null + val subUrl = subObj["url"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val subLang = subObj["language"]?.jsonPrimitive?.contentOrNull ?: "Unknown" + val subName = subObj["name"]?.jsonPrimitive?.contentOrNull + val subHeaders = (subObj["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + com.nuvio.app.features.plugins.PluginSubtitleResult( + url = subUrl, + language = subLang, + name = subName, + headers = subHeaders + ) + }?.takeIf { it.isNotEmpty() } + PluginRuntimeResult( title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", name = item.stringOrNull("name"), @@ -219,6 +238,7 @@ internal object PluginRuntime { peers = item["peers"]?.jsonPrimitive?.intOrNull, infoHash = item.stringOrNull("infoHash"), headers = headers, + subtitles = subtitles, ) }.filter { it.url.isNotBlank() } }.getOrElse { emptyList() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 9012a96c..311442ab 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -9,7 +9,12 @@ import platform.UIKit.UIViewController interface NuvioPlayerBridge { fun createPlayerViewController(): UIViewController fun loadFile(url: String) - fun loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) + fun loadFileWithAudio( + videoUrl: String, + audioUrl: String?, + headersJson: String?, + subtitlesJson: String? = null + ) fun play() fun pause() fun seekTo(positionMs: Long) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 733bf162..0eaba036 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -26,6 +26,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -222,12 +223,13 @@ actual fun PlatformPlayerSurface( } // Load file and set initial state - LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders, externalSubtitles) { bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( - sourceUrl, - sourceAudioUrl, - encodePlaybackHeadersForBridge(sourceHeaders), + videoUrl = sourceUrl, + audioUrl = sourceAudioUrl, + headersJson = encodePlaybackHeadersForBridge(sourceHeaders), + subtitlesJson = encodeExternalSubtitlesForBridge(externalSubtitles), ) if (playWhenReady) { bridge.play() @@ -339,6 +341,13 @@ private fun Int.toHexByte(): String { } } +private fun encodeExternalSubtitlesForBridge(subtitles: List): String? { + if (subtitles.isEmpty()) return null + return runCatching { + Json.encodeToString(subtitles) + }.getOrNull() +} + private fun encodePlaybackHeadersForBridge(headers: Map): String? { val sanitized = sanitizePlaybackHeaders(headers) if (sanitized.isEmpty()) { diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index afcdc601..7cd94951 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -16,13 +16,41 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { } func loadFile(url: String) { playerVC?.loadFile(url) } - func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) { + func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) { playerVC?.loadFile( videoUrl, audioUrl: audioUrl, - requestHeaders: parseRequestHeaders(headersJson) + requestHeaders: parseRequestHeaders(headersJson), + subtitles: parseSubtitles(subtitlesJson) ) } + + private func parseSubtitles(_ json: String?) -> [PluginSubtitle] { + guard + let json, + let data = json.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + return raw.compactMap { dict in + guard let url = dict["url"] as? String else { return nil } + return PluginSubtitle( + url: url, + language: dict["language"] as? String ?? "Unknown", + name: dict["name"] as? String, + headers: dict["headers"] as? [String: String] + ) + } + } +} + +struct PluginSubtitle { + val url: String + val language: String + val name: String? + val headers: [String: String]? +} func play() { playerVC?.playPlayback() } func pause() { playerVC?.pausePlayback() } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } @@ -172,6 +200,7 @@ private struct PendingLoadRequest { let urlString: String let audioUrl: String? let requestHeaders: [String: String] + let subtitles: [PluginSubtitle] let queuedAtUptime: TimeInterval } @@ -357,11 +386,12 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API - func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:], subtitles: [PluginSubtitle] = []) { let request = PendingLoadRequest( urlString: urlString, audioUrl: audioUrl, requestHeaders: requestHeaders, + subtitles: subtitles, queuedAtUptime: ProcessInfo.processInfo.systemUptime ) @@ -409,6 +439,13 @@ final class MPVPlayerViewController: UIViewController { self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } + + // Add external subtitles + for subtitle in request.subtitles { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false) + } + } } private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {