From b71314b8f61ec22fa4ca23eef14e2a8aa047bcd5 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 27 Oct 2025 19:35:37 +0530 Subject: [PATCH] subtitle improvements --- src/components/player/AndroidVideoPlayer.tsx | 39 ++ src/components/player/KSPlayerCore.tsx | 39 ++ .../player/subtitles/CustomSubtitles.tsx | 74 ++- src/components/player/utils/playerTypes.ts | 13 + src/components/player/utils/playerUtils.ts | 103 +---- src/components/player/utils/subtitleParser.ts | 435 ++++++++++++++++++ src/services/stremioService.ts | 1 + 7 files changed, 590 insertions(+), 114 deletions(-) create mode 100644 src/components/player/utils/subtitleParser.ts diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 2d06569f..a7cb430f 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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(null); const [customSubtitles, setCustomSubtitles] = useState([]); const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); const [customSubtitleVersion, setCustomSubtitleVersion] = useState(0); const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); const [subtitleBackground, setSubtitleBackground] = useState(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} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 46f1f2d6..81239452 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -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(null); const [customSubtitles, setCustomSubtitles] = useState([]); const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentFormattedSegments, setCurrentFormattedSegments] = useState([]); const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); const [subtitleBackground, setSubtitleBackground] = useState(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} /> diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx index 86c9813f..3325d94f 100644 --- a/src/components/player/subtitles/CustomSubtitles.tsx +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -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 = ({ @@ -47,6 +50,7 @@ export const CustomSubtitles: React.FC = ({ controlsFixedOffset, letterSpacing = 0, lineHeightMultiplier = 1.2, + formattedSegments, }) => { if (!useCustomSubtitles || !currentSubtitle) return null; @@ -77,6 +81,39 @@ export const CustomSubtitles: React.FC = ({ 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 ( + + {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 ( + + {segment.text} + + ); + })} + + ); + }; + return ( = ({ ) : ( // No outline: use RN Text with (optional) shadow - - {currentSubtitle} - + formattedSegments && formattedSegments.length > 0 ? ( + // Render formatted segments if available + formattedSegments.map((lineSegments, lineIdx) => + renderFormattedText(lineSegments, lineIdx, 'formatted') + ) + ) : ( + + {currentSubtitle} + + ) )} diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts index b15a2b90..b42e87f7 100644 --- a/src/components/player/utils/playerTypes.ts +++ b/src/components/player/utils/playerTypes.ts @@ -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 diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts index 53425990..43de87f6 100644 --- a/src/components/player/utils/playerUtils.ts +++ b/src/components/player/utils/playerUtils.ts @@ -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); }; \ No newline at end of file diff --git a/src/components/player/utils/subtitleParser.ts b/src/components/player/utils/subtitleParser.ts new file mode 100644 index 00000000..8ba1f8b7 --- /dev/null +++ b/src/components/player/utils/subtitleParser.ts @@ -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 = { + 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; 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(' 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 { + const attrs: Record = {}; + 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 { + const props: Partial = {}; + + 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); + } +} + diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 61fb0ae2..f103effb 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -67,6 +67,7 @@ export interface Subtitle { fps?: number; addon?: string; addonName?: string; + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint } export interface Stream {