mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
subtitle improvements
This commit is contained in:
parent
d9fcc085a6
commit
b71314b8f6
7 changed files with 590 additions and 114 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
435
src/components/player/utils/subtitleParser.ts
Normal file
435
src/components/player/utils/subtitleParser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +67,7 @@ export interface Subtitle {
|
|||
fps?: number;
|
||||
addon?: string;
|
||||
addonName?: string;
|
||||
format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
|
|
|
|||
Loading…
Reference in a new issue