From 4c527a8d8d378ce787d1237ac7c4d71bc0ceb0ee Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 6 Jun 2026 00:04:46 +0530 Subject: [PATCH] feat(player): improve plugin subtitle handling --- .../player/PlayerSubtitleCueParser.kt | 162 +++++++++++++++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 37 +++- 2 files changed, 186 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 67b52638d..9e1b03817 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -11,10 +11,39 @@ object PlayerSubtitleCueParser { .trim() if (normalized.isBlank()) return emptyList() - return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) { - parseWebVtt(normalized) - } else { - parseSrt(normalized) + return when (detectSubtitleFormat(sourceUrl, normalized)) { + SubtitleFormatHint.WebVtt -> parseWebVtt(normalized) + SubtitleFormatHint.Ass -> parseAss(normalized) + SubtitleFormatHint.Ttml -> parseTtml(normalized) + SubtitleFormatHint.Srt -> parseSrt(normalized) + } + } + + private enum class SubtitleFormatHint { + Srt, + WebVtt, + Ass, + Ttml, + } + + private fun detectSubtitleFormat(sourceUrl: String?, text: String): SubtitleFormatHint { + val sourcePath = sourceUrl + ?.substringBefore('?') + ?.substringBefore('#') + ?.lowercase() + .orEmpty() + val sample = text.take(4_096).lowercase() + + return when { + sourcePath.endsWith(".vtt") || sourcePath.endsWith(".webvtt") || text.startsWith("WEBVTT") -> + SubtitleFormatHint.WebVtt + sourcePath.endsWith(".ass") || sourcePath.endsWith(".ssa") || + (sample.contains("[events]") && sample.contains("dialogue:")) -> + SubtitleFormatHint.Ass + sourcePath.endsWith(".ttml") || sourcePath.endsWith(".dfxp") || sourcePath.endsWith(".xml") || + Regex("""]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) -> + SubtitleFormatHint.Ttml + else -> SubtitleFormatHint.Srt } } @@ -53,6 +82,68 @@ object PlayerSubtitleCueParser { } .sortedBy { it.startTimeMs } + private fun parseAss(text: String): List { + var inEventsSection = false + var formatFields: List? = null + + return text.lines() + .mapNotNull { rawLine -> + val line = rawLine.trim() + when { + line.equals("[Events]", ignoreCase = true) -> { + inEventsSection = true + null + } + line.startsWith("[") && line.endsWith("]") -> { + inEventsSection = false + null + } + inEventsSection && line.startsWith("Format:", ignoreCase = true) -> { + formatFields = line.substringAfter(':') + .split(',') + .map { it.trim() } + null + } + inEventsSection && line.startsWith("Dialogue:", ignoreCase = true) -> + parseAssDialogue(line.substringAfter(':'), formatFields) + else -> null + } + } + .sortedBy { it.startTimeMs } + } + + private fun parseAssDialogue(payload: String, formatFields: List?): SubtitleSyncCue? { + val fields = formatFields.orEmpty() + val parts = payload + .split(',', limit = fields.ifEmpty { defaultAssFormatFields }.size) + .map { it.trim() } + val startIndex = fields.indexOfField("Start").takeIf { it >= 0 } ?: 1 + val textIndex = fields.indexOfField("Text").takeIf { it >= 0 } ?: 9 + + if (parts.size <= startIndex || parts.size <= textIndex) return null + val start = parseTimestamp(parts[startIndex]) ?: return null + val body = parts[textIndex] + .cleanAssCueText() + .cleanSubtitleCueText() + return if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + + private fun parseTtml(text: String): List = + Regex("""]*)>(.*?)

""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + .findAll(text) + .mapNotNull { match -> + val attrs = match.groupValues[1] + val startRaw = attrs.attributeValue("begin") + ?: attrs.attributeValue("start") + ?: return@mapNotNull null + val start = parseTtmlTimestamp(startRaw) ?: return@mapNotNull null + val body = match.groupValues[2] + .replace(Regex("""""", RegexOption.IGNORE_CASE), " ") + .cleanSubtitleCueText() + if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + .sortedBy { it.startTimeMs } + private fun parseCueStart(timingLine: String): Long? { val startPart = timingLine.substringBefore("-->").trim() return parseTimestamp(startPart) @@ -76,12 +167,75 @@ object PlayerSubtitleCueParser { return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis) } + private fun parseTtmlTimestamp(raw: String): Long? { + val cleaned = raw.trim().substringBefore(' ') + if (cleaned.isBlank()) return null + + parseClockTimeWithFrames(cleaned)?.let { return it } + parseTimestamp(cleaned)?.let { return it } + + val match = Regex("""^([0-9]+(?:\.[0-9]+)?)(ms|h|m|s)$""", RegexOption.IGNORE_CASE) + .matchEntire(cleaned) + ?: return null + val value = match.groupValues[1].toDoubleOrNull() ?: return null + val multiplier = when (match.groupValues[2].lowercase()) { + "h" -> 3_600_000.0 + "m" -> 60_000.0 + "s" -> 1_000.0 + "ms" -> 1.0 + else -> return null + } + return max(0L, (value * multiplier).toLong()) + } + + private fun parseClockTimeWithFrames(raw: String): Long? { + val parts = raw.split(':') + if (parts.size != 4) return null + + val hours = parts[0].toLongOrNull() ?: return null + val minutes = parts[1].toLongOrNull() ?: return null + val seconds = parts[2].toLongOrNull() ?: return null + val frames = parts[3].substringBefore('.').toLongOrNull() ?: return null + return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + frames * 1_000L / 30L) + } + + private val defaultAssFormatFields = listOf( + "Layer", + "Start", + "End", + "Style", + "Name", + "MarginL", + "MarginR", + "MarginV", + "Effect", + "Text", + ) + + private fun List.indexOfField(name: String): Int = + indexOfFirst { it.equals(name, ignoreCase = true) } + + private fun String.attributeValue(name: String): String? = + Regex("""\b${Regex.escape(name)}\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + + private fun String.cleanAssCueText(): String = + replace(Regex("""\{[^}]*}"""), "") + .replace("\\N", " ") + .replace("\\n", " ") + .replace("\\h", " ") + private fun String.cleanSubtitleCueText(): String = replace(Regex("<[^>]+>"), "") .replace(" ", " ") .replace("&", "&") .replace("<", "<") .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") .replace(Regex("\\s+"), " ") .trim() } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index c053d781e..7eb3126dc 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -43,14 +43,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } } -} -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) } @@ -198,6 +191,13 @@ struct PluginSubtitle { } } +struct PluginSubtitle { + let url: String + let language: String + let name: String? + let headers: [String: String]? +} + // MARK: - Track Info struct TrackInfo { @@ -453,10 +453,9 @@ final class MPVPlayerViewController: UIViewController { } } - // 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) + self?.addSubtitle(subtitle, mode: "auto") } } } @@ -598,6 +597,26 @@ final class MPVPlayerViewController: UIViewController { command("sub-add", args: [url, "select"]) } + private func addSubtitle(_ subtitle: PluginSubtitle, mode: String) { + guard mpv != nil else { return } + let subtitleHeaders = sanitizeRequestHeaders(subtitle.headers ?? [:]) + let previousHeaders = activeRequestHeaders + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders.merging(subtitleHeaders) { _, subtitleValue in subtitleValue }) + } + + command( + "sub-add", + args: [subtitle.url, mode, subtitle.name ?? subtitle.language, subtitle.language], + checkForErrors: false + ) + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders) + } + } + func removeExternalSubtitles() { guard mpv != nil else { return } let count = getInt("track-list/count")