mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-04 09:19:06 +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,
|
ResizeModeType,
|
||||||
WyzieSubtitle,
|
WyzieSubtitle,
|
||||||
SubtitleCue,
|
SubtitleCue,
|
||||||
|
SubtitleSegment,
|
||||||
RESUME_PREF_KEY,
|
RESUME_PREF_KEY,
|
||||||
RESUME_PREF,
|
RESUME_PREF,
|
||||||
SUBTITLE_SIZE_KEY
|
SUBTITLE_SIZE_KEY
|
||||||
|
|
@ -449,6 +450,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const pinchRef = useRef<PinchGestureHandler>(null);
|
const pinchRef = useRef<PinchGestureHandler>(null);
|
||||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||||
|
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||||
const [customSubtitleVersion, setCustomSubtitleVersion] = useState<number>(0);
|
const [customSubtitleVersion, setCustomSubtitleVersion] = useState<number>(0);
|
||||||
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
||||||
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
||||||
|
|
@ -2833,6 +2835,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
if (currentSubtitle !== '') {
|
if (currentSubtitle !== '') {
|
||||||
setCurrentSubtitle('');
|
setCurrentSubtitle('');
|
||||||
}
|
}
|
||||||
|
if (currentFormattedSegments.length > 0) {
|
||||||
|
setCurrentFormattedSegments([]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
|
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
|
||||||
|
|
@ -2841,6 +2846,39 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
);
|
);
|
||||||
const newSubtitle = currentCue ? currentCue.text : '';
|
const newSubtitle = currentCue ? currentCue.text : '';
|
||||||
setCurrentSubtitle(newSubtitle);
|
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]);
|
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3817,6 +3855,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
bottomOffset={subtitleBottomOffset}
|
bottomOffset={subtitleBottomOffset}
|
||||||
letterSpacing={subtitleLetterSpacing}
|
letterSpacing={subtitleLetterSpacing}
|
||||||
lineHeightMultiplier={subtitleLineHeightMultiplier}
|
lineHeightMultiplier={subtitleLineHeightMultiplier}
|
||||||
|
formattedSegments={currentFormattedSegments}
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
ResizeModeType,
|
ResizeModeType,
|
||||||
WyzieSubtitle,
|
WyzieSubtitle,
|
||||||
SubtitleCue,
|
SubtitleCue,
|
||||||
|
SubtitleSegment,
|
||||||
RESUME_PREF_KEY,
|
RESUME_PREF_KEY,
|
||||||
RESUME_PREF,
|
RESUME_PREF,
|
||||||
SUBTITLE_SIZE_KEY
|
SUBTITLE_SIZE_KEY
|
||||||
|
|
@ -179,6 +180,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const pinchRef = useRef<PinchGestureHandler>(null);
|
const pinchRef = useRef<PinchGestureHandler>(null);
|
||||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||||
|
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||||
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
|
||||||
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(false);
|
||||||
// External subtitle customization
|
// External subtitle customization
|
||||||
|
|
@ -2200,6 +2202,9 @@ const KSPlayerCore: React.FC = () => {
|
||||||
if (currentSubtitle !== '') {
|
if (currentSubtitle !== '') {
|
||||||
setCurrentSubtitle('');
|
setCurrentSubtitle('');
|
||||||
}
|
}
|
||||||
|
if (currentFormattedSegments.length > 0) {
|
||||||
|
setCurrentFormattedSegments([]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
|
const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2;
|
||||||
|
|
@ -2208,6 +2213,39 @@ const KSPlayerCore: React.FC = () => {
|
||||||
);
|
);
|
||||||
const newSubtitle = currentCue ? currentCue.text : '';
|
const newSubtitle = currentCue ? currentCue.text : '';
|
||||||
setCurrentSubtitle(newSubtitle);
|
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]);
|
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
|
||||||
|
|
||||||
// Load global subtitle settings
|
// Load global subtitle settings
|
||||||
|
|
@ -3113,6 +3151,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
bottomOffset={subtitleBottomOffset}
|
bottomOffset={subtitleBottomOffset}
|
||||||
letterSpacing={subtitleLetterSpacing}
|
letterSpacing={subtitleLetterSpacing}
|
||||||
lineHeightMultiplier={subtitleLineHeightMultiplier}
|
lineHeightMultiplier={subtitleLineHeightMultiplier}
|
||||||
|
formattedSegments={currentFormattedSegments}
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 126 : 106}
|
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 { View, Text } from 'react-native';
|
||||||
import Svg, { Text as SvgText, TSpan } from 'react-native-svg';
|
import Svg, { Text as SvgText, TSpan } from 'react-native-svg';
|
||||||
import { styles } from '../utils/playerStyles';
|
import { styles } from '../utils/playerStyles';
|
||||||
|
import { SubtitleSegment } from '../utils/playerTypes';
|
||||||
|
|
||||||
interface CustomSubtitlesProps {
|
interface CustomSubtitlesProps {
|
||||||
useCustomSubtitles: boolean;
|
useCustomSubtitles: boolean;
|
||||||
|
|
@ -25,6 +26,8 @@ interface CustomSubtitlesProps {
|
||||||
controlsFixedOffset?: number; // fixed px when controls visible (ignores user offset)
|
controlsFixedOffset?: number; // fixed px when controls visible (ignores user offset)
|
||||||
letterSpacing?: number;
|
letterSpacing?: number;
|
||||||
lineHeightMultiplier?: number; // multiplies subtitleSize
|
lineHeightMultiplier?: number; // multiplies subtitleSize
|
||||||
|
// New: support for formatted subtitle segments
|
||||||
|
formattedSegments?: SubtitleSegment[][]; // Segments per line
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
||||||
|
|
@ -47,6 +50,7 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
||||||
controlsFixedOffset,
|
controlsFixedOffset,
|
||||||
letterSpacing = 0,
|
letterSpacing = 0,
|
||||||
lineHeightMultiplier = 1.2,
|
lineHeightMultiplier = 1.2,
|
||||||
|
formattedSegments,
|
||||||
}) => {
|
}) => {
|
||||||
if (!useCustomSubtitles || !currentSubtitle) return null;
|
if (!useCustomSubtitles || !currentSubtitle) return null;
|
||||||
|
|
||||||
|
|
@ -77,6 +81,39 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
||||||
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;
|
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;
|
||||||
const svgHeight = lines.length * displayLineHeight;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -157,21 +194,28 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
||||||
</Svg>
|
</Svg>
|
||||||
) : (
|
) : (
|
||||||
// No outline: use RN Text with (optional) shadow
|
// No outline: use RN Text with (optional) shadow
|
||||||
<Text style={[
|
formattedSegments && formattedSegments.length > 0 ? (
|
||||||
styles.customSubtitleText,
|
// Render formatted segments if available
|
||||||
{
|
formattedSegments.map((lineSegments, lineIdx) =>
|
||||||
color: textColor,
|
renderFormattedText(lineSegments, lineIdx, 'formatted')
|
||||||
fontFamily,
|
)
|
||||||
textAlign: align,
|
) : (
|
||||||
letterSpacing,
|
<Text style={[
|
||||||
fontSize: subtitleSize * inverseScale,
|
styles.customSubtitleText,
|
||||||
lineHeight: subtitleSize * lineHeightMultiplier * inverseScale,
|
{
|
||||||
transform: [{ scale: inverseScale }],
|
color: textColor,
|
||||||
},
|
fontFamily,
|
||||||
shadowStyle,
|
textAlign: align,
|
||||||
]}>
|
letterSpacing,
|
||||||
{currentSubtitle}
|
fontSize: subtitleSize * inverseScale,
|
||||||
</Text>
|
lineHeight: subtitleSize * lineHeightMultiplier * inverseScale,
|
||||||
|
transform: [{ scale: inverseScale }],
|
||||||
|
},
|
||||||
|
shadowStyle,
|
||||||
|
]}>
|
||||||
|
{currentSubtitle}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,23 @@ export interface VlcMediaEvent {
|
||||||
selectedTextTrack?: number;
|
selectedTextTrack?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSegment {
|
||||||
|
text: string;
|
||||||
|
italic?: boolean;
|
||||||
|
bold?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
color?: string;
|
||||||
|
fontName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
text: string;
|
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
|
// Add interface for Wyzie subtitle API response
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { SubtitleCue } from './playerTypes';
|
import { SubtitleCue } from './playerTypes';
|
||||||
|
import { parseSRT as parseSRTEnhanced, parseSubtitle } from './subtitleParser';
|
||||||
|
|
||||||
// Debug flag - set back to false to disable verbose logging
|
// Debug flag - set back to false to disable verbose logging
|
||||||
// WARNING: Setting this to true currently causes infinite render loops
|
// 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[] => {
|
export const parseSRT = (srtContent: string): SubtitleCue[] => {
|
||||||
const cues: SubtitleCue[] = [];
|
// Use the new enhanced parser from subtitleParser.ts
|
||||||
|
return parseSRTEnhanced(srtContent);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
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;
|
fps?: number;
|
||||||
addon?: string;
|
addon?: string;
|
||||||
addonName?: string;
|
addonName?: string;
|
||||||
|
format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stream {
|
export interface Stream {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue