feat(player): improve plugin subtitle handling

This commit is contained in:
paregi12 2026-06-06 00:04:46 +05:30
parent 4b466b2858
commit 4c527a8d8d
2 changed files with 186 additions and 13 deletions

View file

@ -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("""<tt[\s>]""", 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<SubtitleSyncCue> {
var inEventsSection = false
var formatFields: List<String>? = 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<String>?): 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<SubtitleSyncCue> =
Regex("""<p\b([^>]*)>(.*?)</p>""", 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("""<br\s*/?>""", 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<String>.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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace(Regex("\\s+"), " ")
.trim()
}

View file

@ -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")