mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 23:12:24 +00:00
feat(player): improve plugin subtitle handling
This commit is contained in:
parent
4b466b2858
commit
4c527a8d8d
2 changed files with 186 additions and 13 deletions
|
|
@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue