subtitle improvements

This commit is contained in:
tapframe 2025-10-27 19:35:37 +05:30
parent d9fcc085a6
commit b71314b8f6
7 changed files with 590 additions and 114 deletions

View file

@ -27,6 +27,7 @@ import {
ResizeModeType,
WyzieSubtitle,
SubtitleCue,
SubtitleSegment,
RESUME_PREF_KEY,
RESUME_PREF,
SUBTITLE_SIZE_KEY
@ -449,6 +450,7 @@ const AndroidVideoPlayer: React.FC = () => {
const pinchRef = useRef<PinchGestureHandler>(null);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [customSubtitleVersion, setCustomSubtitleVersion] = useState<number>(0);
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
@ -2833,6 +2835,9 @@ const AndroidVideoPlayer: React.FC = () => {
if (currentSubtitle !== '') {
setCurrentSubtitle('');
}
if (currentFormattedSegments.length > 0) {
setCurrentFormattedSegments([]);
}
return;
}
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
@ -2841,6 +2846,39 @@ const AndroidVideoPlayer: React.FC = () => {
);
const newSubtitle = currentCue ? currentCue.text : '';
setCurrentSubtitle(newSubtitle);
// Extract formatted segments from current cue
if (currentCue?.formattedSegments) {
// Split by newlines to get per-line segments
const lines = (currentCue.text || '').split(/\r?\n/);
const segmentsPerLine: SubtitleSegment[][] = [];
let segmentIndex = 0;
for (const line of lines) {
const lineSegments: SubtitleSegment[] = [];
const words = line.split(/(\s+)/);
for (const word of words) {
if (word.trim()) {
if (segmentIndex < currentCue.formattedSegments.length) {
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
segmentIndex++;
} else {
// Fallback if segment count doesn't match
lineSegments.push({ text: word });
}
}
}
if (lineSegments.length > 0) {
segmentsPerLine.push(lineSegments);
}
}
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
} else {
setCurrentFormattedSegments([]);
}
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
useEffect(() => {
@ -3817,6 +3855,7 @@ const AndroidVideoPlayer: React.FC = () => {
bottomOffset={subtitleBottomOffset}
letterSpacing={subtitleLetterSpacing}
lineHeightMultiplier={subtitleLineHeightMultiplier}
formattedSegments={currentFormattedSegments}
controlsVisible={showControls}
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
/>

View file

@ -28,6 +28,7 @@ import {
ResizeModeType,
WyzieSubtitle,
SubtitleCue,
SubtitleSegment,
RESUME_PREF_KEY,
RESUME_PREF,
SUBTITLE_SIZE_KEY
@ -179,6 +180,7 @@ const KSPlayerCore: React.FC = () => {
const pinchRef = useRef<PinchGestureHandler>(null);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
// External subtitle customization
@ -2200,6 +2202,9 @@ const KSPlayerCore: React.FC = () => {
if (currentSubtitle !== '') {
setCurrentSubtitle('');
}
if (currentFormattedSegments.length > 0) {
setCurrentFormattedSegments([]);
}
return;
}
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
@ -2208,6 +2213,39 @@ const KSPlayerCore: React.FC = () => {
);
const newSubtitle = currentCue ? currentCue.text : '';
setCurrentSubtitle(newSubtitle);
// Extract formatted segments from current cue
if (currentCue?.formattedSegments) {
// Split by newlines to get per-line segments
const lines = (currentCue.text || '').split(/\r?\n/);
const segmentsPerLine: SubtitleSegment[][] = [];
let segmentIndex = 0;
for (const line of lines) {
const lineSegments: SubtitleSegment[] = [];
const words = line.split(/(\s+)/);
for (const word of words) {
if (word.trim()) {
if (segmentIndex < currentCue.formattedSegments.length) {
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
segmentIndex++;
} else {
// Fallback if segment count doesn't match
lineSegments.push({ text: word });
}
}
}
if (lineSegments.length > 0) {
segmentsPerLine.push(lineSegments);
}
}
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
} else {
setCurrentFormattedSegments([]);
}
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
// Load global subtitle settings
@ -3113,6 +3151,7 @@ const KSPlayerCore: React.FC = () => {
bottomOffset={subtitleBottomOffset}
letterSpacing={subtitleLetterSpacing}
lineHeightMultiplier={subtitleLineHeightMultiplier}
formattedSegments={currentFormattedSegments}
controlsVisible={showControls}
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 126 : 106}
/>

View file

@ -2,6 +2,7 @@ import React from 'react';
import { View, Text } from 'react-native';
import Svg, { Text as SvgText, TSpan } from 'react-native-svg';
import { styles } from '../utils/playerStyles';
import { SubtitleSegment } from '../utils/playerTypes';
interface CustomSubtitlesProps {
useCustomSubtitles: boolean;
@ -25,6 +26,8 @@ interface CustomSubtitlesProps {
controlsFixedOffset?: number; // fixed px when controls visible (ignores user offset)
letterSpacing?: number;
lineHeightMultiplier?: number; // multiplies subtitleSize
// New: support for formatted subtitle segments
formattedSegments?: SubtitleSegment[][]; // Segments per line
}
export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
@ -47,6 +50,7 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
controlsFixedOffset,
letterSpacing = 0,
lineHeightMultiplier = 1.2,
formattedSegments,
}) => {
if (!useCustomSubtitles || !currentSubtitle) return null;
@ -77,6 +81,39 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;
const svgHeight = lines.length * displayLineHeight;
// Helper to render formatted segments
const renderFormattedText = (segments: SubtitleSegment[], lineIndex: number, keyPrefix: string) => {
if (!segments || segments.length === 0) return null;
return (
<Text key={`${keyPrefix}-line-${lineIndex}`} style={{
color: textColor,
fontFamily,
textAlign: align,
letterSpacing,
fontSize: displayFontSize,
lineHeight: displayLineHeight,
}}>
{segments.map((segment, segIdx) => {
const segmentStyle: any = {};
if (segment.italic) segmentStyle.fontStyle = 'italic';
if (segment.bold) segmentStyle.fontWeight = 'bold';
if (segment.underline) segmentStyle.textDecorationLine = 'underline';
if (segment.color) segmentStyle.color = segment.color;
// Apply outline/shadow to individual segments if needed
const mergedShadowStyle = (textShadow && !useCrispSvgOutline) ? shadowStyle : {};
return (
<Text key={`${keyPrefix}-seg-${segIdx}`} style={[segmentStyle, mergedShadowStyle]}>
{segment.text}
</Text>
);
})}
</Text>
);
};
return (
<View
style={[
@ -157,21 +194,28 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
</Svg>
) : (
// No outline: use RN Text with (optional) shadow
<Text style={[
styles.customSubtitleText,
{
color: textColor,
fontFamily,
textAlign: align,
letterSpacing,
fontSize: subtitleSize * inverseScale,
lineHeight: subtitleSize * lineHeightMultiplier * inverseScale,
transform: [{ scale: inverseScale }],
},
shadowStyle,
]}>
{currentSubtitle}
</Text>
formattedSegments && formattedSegments.length > 0 ? (
// Render formatted segments if available
formattedSegments.map((lineSegments, lineIdx) =>
renderFormattedText(lineSegments, lineIdx, 'formatted')
)
) : (
<Text style={[
styles.customSubtitleText,
{
color: textColor,
fontFamily,
textAlign: align,
letterSpacing,
fontSize: subtitleSize * inverseScale,
lineHeight: subtitleSize * lineHeightMultiplier * inverseScale,
transform: [{ scale: inverseScale }],
},
shadowStyle,
]}>
{currentSubtitle}
</Text>
)
)}
</View>
</View>

View file

@ -77,10 +77,23 @@ export interface VlcMediaEvent {
selectedTextTrack?: number;
}
export interface SubtitleSegment {
text: string;
italic?: boolean;
bold?: boolean;
underline?: boolean;
color?: string;
fontName?: string;
}
export interface SubtitleCue {
start: number;
end: number;
text: string;
// New fields for advanced features
formattedSegments?: SubtitleSegment[]; // Rich text with formatting
position?: { x?: number; y?: number; align?: string }; // Position tags
rawText?: string; // Original text before processing
}
// Add interface for Wyzie subtitle API response

View file

@ -1,6 +1,7 @@
import { logger } from '../../../utils/logger';
import { useEffect } from 'react';
import { SubtitleCue } from './playerTypes';
import { parseSRT as parseSRTEnhanced, parseSubtitle } from './subtitleParser';
// Debug flag - set back to false to disable verbose logging
// WARNING: Setting this to true currently causes infinite render loops
@ -166,104 +167,8 @@ export const formatTime = (seconds: number) => {
}
};
// Enhanced SRT parser function - more robust
// Enhanced SRT parser function - delegates to new parser with formatting support
export const parseSRT = (srtContent: string): SubtitleCue[] => {
const cues: SubtitleCue[] = [];
if (!srtContent || srtContent.trim().length === 0) {
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] SRT Parser: Empty content provided`);
}
return cues;
}
// Normalize line endings and clean up the content
const normalizedContent = srtContent
.replace(/\r\n/g, '\n') // Convert Windows line endings
.replace(/\r/g, '\n') // Convert Mac line endings
.trim();
// Split by double newlines, but also handle cases with multiple empty lines
const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`);
logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`);
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i].trim();
const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0);
if (lines.length >= 3) {
// Find the timestamp line (could be line 1 or 2, depending on numbering)
let timeLineIndex = -1;
let timeMatch = null;
for (let j = 0; j < Math.min(3, lines.length); j++) {
// More flexible time pattern matching
timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/);
if (timeMatch) {
timeLineIndex = j;
break;
}
}
if (timeMatch && timeLineIndex !== -1) {
try {
const startTime =
parseInt(timeMatch[1]) * 3600 +
parseInt(timeMatch[2]) * 60 +
parseInt(timeMatch[3]) +
parseInt(timeMatch[4]) / 1000;
const endTime =
parseInt(timeMatch[5]) * 3600 +
parseInt(timeMatch[6]) * 60 +
parseInt(timeMatch[7]) +
parseInt(timeMatch[8]) / 1000;
// Get text lines (everything after the timestamp line)
const textLines = lines.slice(timeLineIndex + 1);
if (textLines.length > 0) {
const text = textLines
.join('\n')
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic}
.replace(/\\N/g, '\n') // Handle \N newlines
.trim();
if (text.length > 0) {
cues.push({
start: startTime,
end: endTime,
text: text
});
if (DEBUG_MODE && (i < 5 || cues.length <= 10)) {
logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
}
}
}
} catch (error) {
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`);
}
}
} else if (DEBUG_MODE) {
logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`);
}
} else if (DEBUG_MODE && block.length > 0) {
logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`);
}
}
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`);
if (cues.length > 0) {
logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`);
}
}
return cues;
// Use the new enhanced parser from subtitleParser.ts
return parseSRTEnhanced(srtContent);
};

View file

@ -0,0 +1,435 @@
import { logger } from '../../../utils/logger';
import { SubtitleCue, SubtitleSegment } from './playerTypes';
const DEBUG_MODE = false;
/**
* Detect subtitle format from content
*/
export function detectSubtitleFormat(content: string, url?: string): 'srt' | 'vtt' | 'unknown' {
// Check URL extension first
if (url) {
const urlLower = url.toLowerCase();
if (urlLower.includes('.srt')) return 'srt';
if (urlLower.includes('.vtt')) return 'vtt';
}
// Check content patterns
const first100Chars = content.trim().substring(0, 100);
// WebVTT typically starts with "WEBVTT" and has " --> " separator
if (first100Chars.includes('WEBVTT') || first100Chars.match(/\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3}/)) {
return 'vtt';
}
// SRT typically has " --> " separator and uses different timestamp format
if (first100Chars.match(/\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}/)) {
return 'srt';
}
// Default to SRT for backward compatibility
return 'srt';
}
/**
* Parse SRT timestamp
*/
function parseSRTTimestamp(timestamp: string): number {
const match = timestamp.match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/);
if (!match) return 0;
return parseInt(match[1]) * 3600 +
parseInt(match[2]) * 60 +
parseInt(match[3]) +
parseInt(match[4]) / 1000;
}
/**
* Parse SRT position tags {\an1}-{\an9}
* Positions based on numpad layout:
* 7=top-left, 8=top, 9=top-right
* 4=left, 5=center, 6=right
* 1=bottom-left, 2=bottom, 3=bottom-right
*/
function parseSRTPositionTag(text: string): { x?: number; y?: number; align?: string } | undefined {
const match = text.match(/\\{\\an([1-9])\\}/);
if (!match) return undefined;
const pos = parseInt(match[1]);
// Map numpad to alignment
const alignments: Record<number, string> = {
1: 'left', // bottom-left
2: 'center', // bottom-center
3: 'right', // bottom-right
4: 'left', // left
5: 'center', // center (default)
6: 'right', // right
7: 'left', // top-left
8: 'center', // top-center
9: 'right', // top-right
};
const verticalPos = pos <= 3 ? 'bottom' : pos <= 6 ? 'middle' : 'top';
return {
align: alignments[pos] || 'center',
// Could add x/y positioning here if needed
};
}
/**
* Parse HTML-style formatting tags and convert to segments
*/
function parseSubtitleFormatting(text: string): SubtitleSegment[] {
const segments: SubtitleSegment[] = [];
let currentIndex = 0;
let tagStack: Array<{ tag: string; attrs?: Record<string, string>; position: number }> = [];
let segmentText = '';
// Process text character by character or in chunks
const regex = /<(i|b|u|font)(\s+[^>]*)?>|<\/(i|b|u|font)>|{\\an[1-9]}/gi;
let match;
let lastIndex = 0;
// First, extract and save position tags
const anMatches: Array<{ match: string; position: number }> = [];
text.replace(/\{\\an([1-9])\}/gi, (match, offset) => {
anMatches.push({ match, position: offset });
return '';
});
// Remove position tags from text before processing
let cleanText = text.replace(/\{\\an[1-9]\}/gi, '');
// Parse HTML tags
while ((match = regex.exec(cleanText)) !== null) {
// Add text before tag
if (match.index > lastIndex) {
const textBetween = cleanText.substring(lastIndex, match.index);
if (textBetween) {
if (!segmentText && segments.length > 0) {
segments[segments.length - 1].text += textBetween;
} else {
segmentText += textBetween;
}
}
}
if (match[0].startsWith('</')) {
// Closing tag
const tagName = match[3].toLowerCase();
tagStack = tagStack.filter(t => t.tag !== tagName);
// If this closes the last tag, create a segment
if (tagStack.length === 0 && segmentText) {
segments.push({
text: segmentText,
italic: tagName === 'i',
bold: tagName === 'b',
underline: tagName === 'u',
});
segmentText = '';
}
} else {
// Opening tag
const tagName = match[1].toLowerCase();
const attrs = match[2] ? parseAttributes(match[2]) : {};
// Create segment if we have text and this is first tag
if (segmentText && tagStack.length === 0) {
segments.push({
text: segmentText,
...getSegmentProps(tagStack),
});
segmentText = '';
}
tagStack.push({ tag: tagName, attrs, position: match.index });
// Handle font color
if (tagName === 'font' && attrs.color) {
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
lastSegment.color = attrs.color;
}
}
}
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < cleanText.length) {
const remainingText = cleanText.substring(lastIndex);
if (remainingText) {
if (tagStack.length === 0) {
segmentText += remainingText;
if (segmentText) {
segments.push({ text: segmentText, ...getSegmentProps(tagStack) });
}
} else {
// Add to existing segment
if (segments.length > 0) {
segments[segments.length - 1].text += remainingText;
}
}
}
}
// If no segments were created but we have text, add as plain text
if (segments.length === 0 && cleanText.trim()) {
segments.push({ text: cleanText });
}
return segments;
}
/**
* Parse HTML attributes like: color="#FF0000"
*/
function parseAttributes(attrString: string): Record<string, string> {
const attrs: Record<string, string> = {};
const colorMatch = attrString.match(/color=["']([^"']+)["']/i);
if (colorMatch) {
attrs.color = colorMatch[1];
}
return attrs;
}
/**
* Get segment properties from tag stack
*/
function getSegmentProps(tagStack: Array<{ tag: string }>): Partial<SubtitleSegment> {
const props: Partial<SubtitleSegment> = {};
tagStack.forEach(tag => {
if (tag.tag === 'i') props.italic = true;
if (tag.tag === 'b') props.bold = true;
if (tag.tag === 'u') props.underline = true;
});
return props;
}
/**
* Parse SRT format with formatting support
*/
export function parseSRT(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = [];
if (!content || content.trim().length === 0) {
if (DEBUG_MODE) logger.log('[SubtitleParser] Empty content provided');
return cues;
}
// Normalize line endings
const normalizedContent = content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.trim();
// Split by double newlines
const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0);
if (DEBUG_MODE) {
logger.log(`[SubtitleParser] Found ${blocks.length} blocks`);
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i].trim();
const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0);
if (lines.length < 3) continue;
// Find timestamp line
let timeLineIndex = -1;
let timeMatch = null;
for (let j = 0; j < Math.min(3, lines.length); j++) {
timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/);
if (timeMatch) {
timeLineIndex = j;
break;
}
}
if (!timeMatch || timeLineIndex === -1) continue;
try {
const startTime = parseSRTTimestamp(lines[timeLineIndex]);
const endTime = parseSRTTimestamp(lines[timeLineIndex].split(' --> ')[1]);
// Get text lines
const textLines = lines.slice(timeLineIndex + 1);
if (textLines.length === 0) continue;
const rawText = textLines.join('\n');
// Parse position tags
const position = parseSRTPositionTag(rawText);
// Parse formatting
const formattedSegments = parseSubtitleFormatting(rawText);
// Get plain text for backward compatibility
const plainText = formattedSegments.map(s => s.text).join('') || rawText;
cues.push({
start: startTime,
end: endTime,
text: plainText,
rawText,
formattedSegments: formattedSegments.length > 0 ? formattedSegments : undefined,
position,
});
if (DEBUG_MODE && (i < 5 || cues.length <= 10)) {
logger.log(`[SubtitleParser] Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${plainText.substring(0, 50)}"`);
}
} catch (error) {
if (DEBUG_MODE) {
logger.log(`[SubtitleParser] Error parsing block ${i + 1}: ${error}`);
}
}
}
if (DEBUG_MODE) {
logger.log(`[SubtitleParser] Successfully parsed ${cues.length} cues`);
if (cues.length > 0) {
logger.log(`[SubtitleParser] Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`);
}
}
return cues;
}
/**
* Parse WebVTT format
*/
function parseVTTTimestamp(timestamp: string): number {
const match = timestamp.match(/(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})/);
if (!match) return 0;
return parseInt(match[1]) * 3600 +
parseInt(match[2]) * 60 +
parseInt(match[3]) +
parseInt(match[4]) / 1000;
}
/**
* Parse WebVTT alignment from cue settings
*/
function parseVTTAlignment(settings: string): string {
if (settings.includes('align:start')) return 'left';
if (settings.includes('align:end')) return 'right';
return 'center';
}
export function parseWebVTT(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = [];
if (!content || content.trim().length === 0) {
return cues;
}
// Normalize line endings
const normalizedContent = content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
// Skip WEBVTT header and any note/comment blocks
let lines = normalizedContent.split('\n');
let skipHeader = true;
let inNote = false;
const blocks: string[] = [];
let currentBlock: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (skipHeader) {
if (line.startsWith('WEBVTT')) {
skipHeader = false;
continue;
}
continue;
}
if (line.startsWith('NOTE') || line === '') {
if (line.startsWith('NOTE')) inNote = true;
if (inNote && line === '') inNote = false;
continue;
}
if (inNote) continue;
if (line === '') {
if (currentBlock.length > 0) {
blocks.push(currentBlock.join('\n'));
currentBlock = [];
}
} else {
currentBlock.push(line);
}
}
// Add last block
if (currentBlock.length > 0) {
blocks.push(currentBlock.join('\n'));
}
for (const block of blocks) {
const lines = block.split('\n');
if (lines.length < 2) continue;
// Parse timestamp
const timeMatch = lines[0].match(/(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})\s+-->\s+(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})(\s+.*)?/);
if (!timeMatch) continue;
const startTime = parseVTTTimestamp(timeMatch[0].split(' --> ')[0]);
const endTime = parseVTTTimestamp(timeMatch[0].split(' --> ')[1].split(' ')[0]);
const settings = timeMatch[0].includes(' --> ') ? timeMatch[0].split(' --> ')[1].split(' ').slice(1).join(' ') : '';
// Get text
const textLines = lines.slice(1);
if (textLines.length === 0) continue;
const rawText = textLines.join('\n');
const alignment = parseVTTAlignment(settings);
const formattedSegments = parseSubtitleFormatting(rawText);
const plainText = formattedSegments.map(s => s.text).join('') || rawText;
cues.push({
start: startTime,
end: endTime,
text: plainText,
rawText,
formattedSegments: formattedSegments.length > 0 ? formattedSegments : undefined,
position: alignment !== 'center' ? { align: alignment } : undefined,
});
}
return cues;
}
/**
* Auto-detect format and parse subtitle content
*/
export function parseSubtitle(content: string, url?: string): SubtitleCue[] {
const format = detectSubtitleFormat(content, url);
if (DEBUG_MODE) {
logger.log(`[SubtitleParser] Detected format: ${format}`);
}
switch (format) {
case 'vtt':
return parseWebVTT(content);
case 'srt':
default:
return parseSRT(content);
}
}

View file

@ -67,6 +67,7 @@ export interface Subtitle {
fps?: number;
addon?: string;
addonName?: string;
format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint
}
export interface Stream {