diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx index ccc42ed9f..fdc120cad 100644 --- a/source/funkin/Paths.hx +++ b/source/funkin/Paths.hx @@ -104,6 +104,11 @@ class Paths return getPath('data/$key.json', TEXT, library); } + public static function srt(key:String, ?library:String, ?directory:String = "data/"):String + { + return getPath('$directory$key.srt', TEXT, library); + } + public static function sound(key:String, ?library:String):String { return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library); diff --git a/source/funkin/util/SRTUtil.hx b/source/funkin/util/SRTUtil.hx new file mode 100644 index 000000000..61c6b4034 --- /dev/null +++ b/source/funkin/util/SRTUtil.hx @@ -0,0 +1,213 @@ +package funkin.util; + +import flixel.math.FlxMath; +import flixel.util.FlxStringUtil; + +using StringTools; + +class SubtitleEntry +{ + public var id:Int; + public var start:Float; // ms + public var end:Float; // ms + public var text:String; + + public function new(number:Int = 0, start:Float = 0, end:Float = 0, text:String = "") + { + this.id = number; + this.start = start; + this.end = end; + this.text = text; + } + + public function toString():String + { + return FlxStringUtil.getDebugString([ + LabelValuePair.weak("index", id), + LabelValuePair.weak("range", [start, end]), + LabelValuePair.weak("text", text) + ]); + } +} + +class SRTParser +{ + /** + * Parse SRT content from a raw string + */ + public static function parseFromString(s:String):Array + { + if (s == null) return []; + // normalize newlines + var normalized = s.replace("\r\n", "\n").replace("\r", "\n"); + // strip BOM if present (this fixes the case when the very first line contains invisible U+FEFF) + if (normalized.length > 0 && normalized.charCodeAt(0) == 0xFEFF) + normalized = normalized.substr(1); + + // split by double newlines + final blocks = normalized.split("\n\n"); + final out:Array = []; + + var _timmed = null; + var _lines = null; + + for (block in blocks) + { + _timmed = block.trim(); + if (_timmed.length == 0) continue; + _lines = _timmed.split("\n"); + + var idx = 0; + var number = 0; + var timeLine:String = null; + + if (_lines.length > 0 && SubtitleUtils.isNumeric(_lines[0])) + { + number = Std.parseInt(_lines[0]); + idx = 1; + } + if (idx < _lines.length) + { + timeLine = _lines[idx]; + idx++; + } + if (timeLine == null) continue; + + var times = SRTParser.parseTimeLine(timeLine); + if (times == null) continue; + + var textLines = _lines.slice(idx, _lines.length); + var text = textLines.join("\n"); + + out.push(new SubtitleEntry(number, times.start, times.end, text)); + + _lines = null; + } + + out.sort(SubtitleUtils.sortLines); + return out; + } + + public static function parseFromFile(name:String, ?library:String, ?dir:String):Array + { + var rawSRTData:String = ""; + try + rawSRTData = funkin.Assets.getText(Paths.srt(name, library, dir)) + catch (e) + trace(e); + + return SRTParser.parseFromString(rawSRTData); + } + + /** + * Parse line like: 00:01:23,456 --> 00:01:25,678 + */ + static final timeArrowPatterns = ["-->", "->", "—>", "–>"]; + + public static function parseTimeLine(line:String):{start:Float, end:Float} + { + var left:String = null; + var right:String = null; + + for (a in timeArrowPatterns) + { + var parts = line.split(a); + if (parts.length == 2) + { + left = parts[0].trim(); + right = parts[1].trim(); + break; + } + } + + if (left == null || right == null) return null; + var s = parseTimecode(left); + var e = parseTimecode(right); + + if (s < 0 || e < 0) return null; + return {start: s, end: e}; + } + + /** + * Parse a single timecode like 01:02:03,456 or 01:02:03.456 + */ + public static function parseTimecode(t:String):Float + { + var clean = t.trim().replace(",", "."); + var parts = clean.split(":"); + if (parts.length != 3) throw "Invalid timecode: " + t; + var hh = Std.parseInt(parts[0]); + var mm = Std.parseInt(parts[1]); + var ssf = parts[2]; + var secParts = ssf.split("."); + var ss = Std.parseInt(secParts[0]); + var ms = 0; + if (secParts.length > 1) + { + var frac = secParts[1]; + if (frac.length > 3) frac = frac.substr(0, 3); + while (frac.length < 3) + frac += "0"; + ms = Std.parseInt(frac); + } + return (hh * 3600 + mm * 60 + ss + (ms / 1000.0)) * 1000; + } +} + +/** + * Utility helpers for using subtitles at runtime + */ +class SubtitleUtils +{ + /** + * Finds subtitle entry for the current time. + */ + public static function findActive(list:Array, time:Float):SubtitleEntry + { + for (s in list) + { + if (time >= s.start && time <= s.end) return s; + } + return null; + } + + /** + * Finds subtitle entry id for the current time. + */ + public static function findActiveIndex(list:Array, time:Float):Int + { + var lo = 0; + var hi = list.length - 1; + while (lo <= hi) + { + var mid = (lo + hi) >> 1; + var s = list[mid]; + if (time < s.start) hi = mid - 1; + else if (time > s.end) lo = mid + 1; + else + return mid; + } + return -1; + } + + public static function isNumeric(s:String):Bool + { + if (s == null) return false; + var t = s.trim(); + if (t.length == 0) return false; + for (i in 0...t.length) + { + var code = t.charCodeAt(i); + if (code < '0'.code || code > '9'.code) return false; + } + return true; + } + + /** + * Sort lines by their id. + */ + public static function sortLines(line1:SubtitleEntry, line2:SubtitleEntry):Int + { + return FlxMath.numericComparison(line1.id, line2.id); + } +}