import "pkg:/source/utils/misc.bs"
' WebVTT parsing for custom (app-drawn) subtitles. Pure string work with no node
' access, so it is safe on any thread; LoadCaptionTask calls it on its Task thread.
'
' Parse leniently: the input is not always server-written. Jellyfin returns a
' subtitle file unchanged when it is already in the requested format
' (`SubtitleEncoder.GetSubtitles()`), so an external `.vtt` on the user's disk
' (hand-edited, or an SRT renamed to `.vtt`) reaches this parser byte for byte.
' The timestamp grammar vttTimestampToMs() accepts: `[H:]MM:SS.mmm`. The hour field
' is optional in WebVTT. A comma before the milliseconds is not valid WebVTT, but it
' has reached the app in production (#584), and rejecting it would drop every cue in
' such a file rather than show them.
function vttTimestampPattern() as object
return CreateObject("roRegex", "^(?:(\d{1,3}):)?(\d{2}):(\d{2})[.,](\d{3})$", "")
end function
' Milliseconds for one WebVTT timestamp, or -1 when it is not one.
'
' @param {string} text - the bare stamp: no `-->`, no cue settings, no padding
' @param {dynamic} [pattern=invalid] - a vttTimestampPattern(); pass one in when
' converting many stamps, so the regex is compiled once per file rather than per stamp
' @return {integer} milliseconds, or -1
function vttTimestampToMs(text as string, pattern = invalid as dynamic) as integer
if not isValid(pattern) then pattern = vttTimestampPattern()
parts = pattern.match(text)
if parts.count() < 5 then return -1
hours = 0
if isValid(parts[1]) and parts[1] <> "" then hours = parts[1].toInt()
return 3600000 * hours + 60000 * parts[2].toInt() + 1000 * parts[3].toInt() + parts[4].toInt()
end function
' The timestamp at the start of one side of a timing line: the end side can carry cue
' settings after it (`00:28.876 align:start position:10%`), and a CRLF file leaves a
' trailing carriage return.
function vttTimingField(text as string) as string
fields = text.trim().tokenize(" " + chr(9))
if fields.count() = 0 then return ""
return fields[0]
end function
' Parses a WebVTT file into caption lines.
'
' A WebVTT file is blocks separated by blank lines, and only a cue's text is shown:
' the lines after its timing line, up to the next blank line. Everything else (the
' WEBVTT header, NOTE / STYLE / REGION blocks, and a cue identifier, the optional
' line BEFORE a timing line) is skipped, because none of it follows a timing line
' within its block.
'
' A cue whose timing cannot be read is dropped whole, and its text with it: attaching
' that text to the previous cue's timing would show it at the wrong moment.
'
' @param {string} text - the file contents
' @return {object} { entries: [{ start, end, text }], skippedCues: integer, firstSkipped: string }
' - start / end in milliseconds; one entry per text line
' - skippedCues counts cues dropped for an unreadable timing line, firstSkipped
' quotes the first of them
function parseVTT(text as string) as object
stamp = vttTimestampPattern()
result = { entries: [], skippedCues: 0, firstSkipped: "" }
' -1 whenever the current line is not cue text.
curStart = -1
curEnd = -1
for each rawLine in text.split(chr(10))
line = rawLine.trim()
arrow = line.instr("-->")
if line = ""
curStart = -1
else if arrow >= 0
curStart = vttTimestampToMs(vttTimingField(line.left(arrow)), stamp)
curEnd = vttTimestampToMs(vttTimingField(line.mid(arrow + 3)), stamp)
if curStart = -1 or curEnd = -1
if result.skippedCues = 0 then result.firstSkipped = line
result.skippedCues++
curStart = -1
end if
else if curStart <> -1
result.entries.push({ "start": curStart, "end": curEnd, "text": line })
end if
end for
return result
end function