diff --git a/builds/android/1.4.4/app-release.apk b/builds/android/1.4.4/app-release.apk new file mode 100644 index 0000000..58b05f5 Binary files /dev/null and b/builds/android/1.4.4/app-release.apk differ diff --git a/package.json b/package.json index e414a5d..b5285b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Flickv4", - "version": "1.4.3", + "version": "1.4.4", "private": true, "scripts": { "clean:android": "cd android && ./gradlew clean", diff --git a/src/components/MediaPlayer/NetflixMediaPlayer.tsx b/src/components/MediaPlayer/NetflixMediaPlayer.tsx index ef8f8b5..6279690 100644 --- a/src/components/MediaPlayer/NetflixMediaPlayer.tsx +++ b/src/components/MediaPlayer/NetflixMediaPlayer.tsx @@ -145,6 +145,7 @@ const NetflixMediaPlayer: React.FC = ({ const [showSeasonDropdown, setShowSeasonDropdown] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const [seekBarWidth, setSeekBarWidth] = useState(0); + const [bufferedPosition, setBufferedPosition] = useState(0); const videoRef = useRef(null); const seekBarRef = useRef(null); @@ -305,8 +306,11 @@ const NetflixMediaPlayer: React.FC = ({ showControls(); }, [showControls]); - const handleProgress = useCallback(({ currentTime: t }: { currentTime: number }) => { + const handleProgress = useCallback(({ currentTime: t, playableDuration }: { currentTime: number; playableDuration?: number }) => { if (!isSeeking) setCurrentTime(t); + if (typeof playableDuration === 'number' && Number.isFinite(playableDuration)) { + setBufferedPosition(playableDuration); + } onPlaybackProgress(t); }, [isSeeking, onPlaybackProgress]); @@ -321,11 +325,14 @@ const NetflixMediaPlayer: React.FC = ({ }, [onEnd, onPlaybackEnd]); const videoSource = useMemo(() => { - const isCached = playbackUrl.startsWith('file://'); - return { + const useStreamHeaders = + playbackUrl.includes('.m3u8') || + (!playbackUrl.startsWith('file://') && !playbackUrl.startsWith('/')); + const base = { uri: playbackUrl, - ...(isCached ? {} : { headers: VIDEO_STREAM_HEADERS }), + ...(useStreamHeaders ? { headers: VIDEO_STREAM_HEADERS } : {}), }; + return playbackUrl.includes('.m3u8') ? { ...base, type: 'm3u8' as const } : base; }, [playbackUrl]); const handleError = useCallback(() => { @@ -346,8 +353,9 @@ const NetflixMediaPlayer: React.FC = ({ const next = Math.max(0, Math.min(currentTime + offset, duration)); videoRef.current.seek(next); setCurrentTime(next); + onPlaybackProgress(next); showControls(); - }, [currentTime, duration, showControls]); + }, [currentTime, duration, onPlaybackProgress, showControls]); // ── seekbar pan responder ────────────────────────────────────────────────── const seekPanResponder = useMemo(() => @@ -374,16 +382,25 @@ const NetflixMediaPlayer: React.FC = ({ const t = pct * duration; videoRef.current?.seek(t); setCurrentTime(t); + onPlaybackProgress(t); }); setIsSeeking(false); showControls(); }, onPanResponderTerminate: () => setIsSeeking(false), }), - [duration, showControls]); + [duration, onPlaybackProgress, showControls]); // ── computed ─────────────────────────────────────────────────────────────── const progressPct = duration > 0 ? currentTime / duration : 0; + const bufferLinePct = useMemo(() => { + if (duration <= 0) return 0; + const bufferedPct = bufferedPosition / duration; + const cachedPct = cacheStatus.isActive + ? Math.min((currentTime + cacheStatus.cachedSecondsAhead) / duration, 1) + : 0; + return Math.max(bufferedPct, cachedPct); + }, [bufferedPosition, cacheStatus.cachedSecondsAhead, cacheStatus.isActive, currentTime, duration]); const thumbLeft = Math.max(0, progressPct * seekBarWidth - 8); const hasTV = contentType === 'tv' && seasons.length > 0 && episodes.length > 0; const currentEpisodeInfo = episodes.find(e => e.episode_number === selectedEpisode); @@ -434,24 +451,26 @@ const NetflixMediaPlayer: React.FC = ({ return ( {/* ── Video ── */} - )} - {cacheStatus.isActive && cacheStatus.cachedSecondsAhead > 0 && ( - - - {Math.round(cacheStatus.cachedSecondsAhead)}s cached ahead - - - )} - {/* ── Tap to toggle controls ── */} @@ -555,6 +566,9 @@ const NetflixMediaPlayer: React.FC = ({ {...seekPanResponder.panHandlers} > + {bufferLinePct > 0 && ( + + )} {seekBarWidth > 0 && ( { - const cues: SubtitleCue[] = []; - - const blocks = srtContent.trim().split(/\r?\n\r?\n/); - - for (const block of blocks) { - const lines = block.trim().split(/\r?\n/); - if (lines.length < 3) continue; - - const timestampIndex = lines.findIndex(line => line.includes('-->')); - if (timestampIndex === -1) continue; - - const timestampLine = lines[timestampIndex]; - const textLines = lines.slice(timestampIndex + 1); - - const match = timestampLine.match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/); - if (!match) continue; - - const startHours = parseInt(match[1], 10); - const startMinutes = parseInt(match[2], 10); - const startSeconds = parseFloat(`${match[3]}.${match[4]}`); - const endHours = parseInt(match[5], 10); - const endMinutes = parseInt(match[6], 10); - const endSeconds = parseFloat(`${match[7]}.${match[8]}`); - - const start = startHours * 3600 + startMinutes * 60 + startSeconds; - const end = endHours * 3600 + endMinutes * 60 + endSeconds; - - const text = textLines - .join('\n') - .replace(/<[^>]*>/g, '') - .trim(); - - if (text) { - cues.push({ start, end, text }); - } - } - - return cues; -}; - export const SubtitleOverlay: React.FC = ({ subtitleContent, currentTime, @@ -66,8 +20,9 @@ export const SubtitleOverlay: React.FC = ({ delay = 0, style = DEFAULT_SUBTITLE_STYLE, }) => { - const [cues, setCues] = useState([]); - const [currentCue, setCurrentCue] = useState(null); + const [cues, setCues] = useState(() => + subtitleContent ? parseSrtCues(subtitleContent) : [], + ); const computedFontSize = useMemo(() => { const baseSizes = { @@ -124,28 +79,19 @@ export const SubtitleOverlay: React.FC = ({ useEffect(() => { if (subtitleContent) { try { - const parsedCues = parseSRT(subtitleContent); - setCues(parsedCues); + setCues(parseSrtCues(subtitleContent)); } catch { + setCues([]); } } else { setCues([]); - setCurrentCue(null); } }, [subtitleContent]); - useEffect(() => { - if (cues.length === 0) { - setCurrentCue(null); - return; - } - - const adjustedTime = currentTime - delay + 0.5; - const activeCue = cues.find( - cue => adjustedTime >= cue.start && adjustedTime <= cue.end - ); - - setCurrentCue(activeCue ? activeCue.text : null); + const currentCue = useMemo(() => { + if (cues.length === 0) return null; + const adjustedTime = currentTime - delay; + return findActiveCue(cues, adjustedTime)?.text ?? null; }, [currentTime, cues, delay]); if (!currentCue) { diff --git a/src/components/MediaPlayer/controls/BottomBar.tsx b/src/components/MediaPlayer/controls/BottomBar.tsx index 7f32d87..f56713b 100644 --- a/src/components/MediaPlayer/controls/BottomBar.tsx +++ b/src/components/MediaPlayer/controls/BottomBar.tsx @@ -27,6 +27,7 @@ export const BottomBar: React.FC = ({ subtitleDelay = 0, onSubtitleDelayChange, onResetSubtitleDelay, + cachedAheadSeconds = 0, }) => { const [showDelayControls, setShowDelayControls] = useState(false); @@ -61,6 +62,7 @@ export const BottomBar: React.FC = ({ currentPosition={currentPosition} duration={duration} bufferedPosition={bufferedPosition} + cachedAheadSeconds={cachedAheadSeconds} hidden={hidden} onSeek={onSeek} onTimePreview={onTimePreview} diff --git a/src/components/MediaPlayer/controls/Controls.tsx b/src/components/MediaPlayer/controls/Controls.tsx index 5d9a61e..99f8254 100644 --- a/src/components/MediaPlayer/controls/Controls.tsx +++ b/src/components/MediaPlayer/controls/Controls.tsx @@ -49,7 +49,7 @@ const ControlsComponent: React.FC = ({ subtitleDelay = 0, onSubtitleDelayChange, onResetSubtitleDelay, - cacheAheadSeconds, + cachedAheadSeconds = 0, }) => { const navigation = useNavigation(); const [timeLabel, setTimeLabel] = useState(formatTime(0)); @@ -178,14 +178,9 @@ const ControlsComponent: React.FC = ({ subtitleDelay={subtitleDelay} onSubtitleDelayChange={onSubtitleDelayChange} onResetSubtitleDelay={onResetSubtitleDelay} + cachedAheadSeconds={cachedAheadSeconds} /> - {cacheAheadSeconds !== undefined && cacheAheadSeconds > 0 && !isControlsHidden && ( - - {cacheAheadSeconds}s cached ahead - - )} - {readyNext && fullscreen && onNext && ( = ({ currentPosition, duration, bufferedPosition = 0, + cachedAheadSeconds = 0, hidden, onSeek, onTimePreview, @@ -30,6 +31,17 @@ const ProgressBarComponent: React.FC = ({ return clamp(bufferedPosition / duration, 0, 1); }, [bufferedPosition, duration]); + const cachedPercentage = useMemo(() => { + if (!duration || duration === 0 || cachedAheadSeconds <= 0) return 0; + const cachedEnd = currentPosition + cachedAheadSeconds; + return clamp(cachedEnd / duration, 0, 1); + }, [cachedAheadSeconds, currentPosition, duration]); + + const bufferLinePercentage = useMemo( + () => Math.max(bufferedPercentage, cachedPercentage), + [bufferedPercentage, cachedPercentage], + ); + useEffect(() => { if (!isDragging) { dragPercentageRef.current = progressPercentage; @@ -91,8 +103,8 @@ const ProgressBarComponent: React.FC = ({ const displayPercentage = isDragging ? dragPercentage : progressPercentage; const bufferedStyle = useMemo(() => ({ - width: bufferedPercentage * trackWidth, - }), [bufferedPercentage, trackWidth]); + width: bufferLinePercentage * trackWidth, + }), [bufferLinePercentage, trackWidth]); const progressStyle = useMemo(() => ({ width: displayPercentage * trackWidth, @@ -113,7 +125,7 @@ const ProgressBarComponent: React.FC = ({ - {bufferedPercentage > 0 && } + {bufferLinePercentage > 0 && } diff --git a/src/components/MediaPlayer/controls/styles.ts b/src/components/MediaPlayer/controls/styles.ts index fb56838..129a81a 100644 --- a/src/components/MediaPlayer/controls/styles.ts +++ b/src/components/MediaPlayer/controls/styles.ts @@ -227,7 +227,7 @@ export const styles = StyleSheet.create({ top: 0, left: 0, bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.4)', + backgroundColor: 'rgba(255, 255, 255, 0.55)', borderRadius: 2, }, progressForeground: { @@ -284,18 +284,4 @@ export const styles = StyleSheet.create({ fontSize: 11, fontWeight: '600', }, - cacheBadge: { - position: 'absolute', - top: 48, - right: 12, - backgroundColor: 'rgba(0,0,0,0.65)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - cacheBadgeText: { - color: colors.white, - fontSize: 11, - fontWeight: '600', - }, }); diff --git a/src/components/MediaPlayer/controls/types.ts b/src/components/MediaPlayer/controls/types.ts index 8f386fa..e6c8974 100644 --- a/src/components/MediaPlayer/controls/types.ts +++ b/src/components/MediaPlayer/controls/types.ts @@ -34,7 +34,7 @@ export interface ControlsProps { subtitleDelay?: number; onSubtitleDelayChange?: (delta: number) => void; onResetSubtitleDelay?: () => void; - cacheAheadSeconds?: number; + cachedAheadSeconds?: number; } export interface DoubleTapSeekAreaProps { @@ -86,12 +86,14 @@ export interface BottomBarProps { subtitleDelay?: number; onSubtitleDelayChange?: (delta: number) => void; onResetSubtitleDelay?: () => void; + cachedAheadSeconds?: number; } export interface ProgressBarProps { currentPosition: number; duration: number; bufferedPosition?: number; + cachedAheadSeconds?: number; hidden: boolean; onSeek: (time: number) => void; onTimePreview: (time: number) => void; diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index 56f5b1c..529e60f 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -8,6 +8,7 @@ import { useAppState } from '../../hooks/useAppState'; import { usePlaybackCache } from '../../hooks/usePlaybackCache'; import { SubtitleTrack, DEFAULT_SUBTITLE_STYLE } from '../../types'; import { VIDEO_STREAM_HEADERS } from '../../utils/streamHeaders'; +import { convertSrtToVtt } from '../../utils/subtitleUtils'; import { SubtitleSelector } from '../'; import { SubtitleOverlay } from './SubtitleOverlay'; import Controls from './controls'; @@ -23,33 +24,6 @@ const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); const RESIZE_MODES = ['contain', 'cover', 'stretch', 'none'] as const; const NEXT_EPISODE_THRESHOLD = 150; -const convertSrtToVtt = (srtContent: string): string => { - // Add VTT header - let vttContent = 'WEBVTT\n\n'; - - // Split by double newline to get subtitle blocks - const blocks = srtContent.trim().split(/\r?\n\r?\n/); - - for (const block of blocks) { - const lines = block.trim().split(/\r?\n/); - if (lines.length < 2) continue; - - // Find the timestamp line (contains -->) - const timestampIndex = lines.findIndex(line => line.includes('-->')); - if (timestampIndex === -1) continue; - - // Convert SRT timestamps (00:00:01,000) to VTT format (00:00:01.000) - const timestampLine = lines[timestampIndex].replace(/,/g, '.'); - const textLines = lines.slice(timestampIndex + 1); - - if (textLines.length > 0) { - vttContent += `${timestampLine}\n${textLines.join('\n')}\n\n`; - } - } - - return vttContent; -}; - interface MediaPlayerProps { videoUrl: string; title: string; @@ -96,6 +70,7 @@ const MediaPlayer: React.FC = ({ const [hasStartedFromProgress, setHasStartedFromProgress] = useState(false); const [showSubtitleSelector, setShowSubtitleSelector] = useState(false); const [isSeeking, setIsSeeking] = useState(false); + const [bufferedPosition, setBufferedPosition] = useState(0); const [subtitleContent, setSubtitleContent] = useState(null); const [subtitleVttPath, setSubtitleVttPath] = useState(null); @@ -221,8 +196,11 @@ const MediaPlayer: React.FC = ({ } }, [currentTime, hasStartedFromProgress]); - const handleProgress = useCallback(({ currentTime: time }: { currentTime: number }) => { + const handleProgress = useCallback(({ currentTime: time, playableDuration }: { currentTime: number; playableDuration?: number }) => { setCurrentTime(time); + if (typeof playableDuration === 'number' && Number.isFinite(playableDuration)) { + setBufferedPosition(playableDuration); + } onPlaybackProgress(time); }, [onPlaybackProgress]); @@ -250,9 +228,10 @@ const MediaPlayer: React.FC = ({ const clampedTime = Math.max(0, Math.min(time, duration)); videoRef.current.seek(clampedTime); setCurrentTime(clampedTime); + onPlaybackProgress(clampedTime); showControls(); } - }, [duration, showControls]); + }, [duration, onPlaybackProgress, showControls]); const handleResizeModeToggle = useCallback(() => { setResizeMode(prev => (prev + 1) % RESIZE_MODES.length); @@ -295,7 +274,8 @@ const MediaPlayer: React.FC = ({ const sanitizedTitle = selectedSubtitle.title.replace(/[^a-z0-9]/gi, '_').toLowerCase() + `_${contentId}` + (season && episode ? `_s${season}e${episode}` : ''); const srtFilename = `${sanitizedTitle}_${selectedSubtitle.language}.srt`; - const vttFilename = `${sanitizedTitle}_${selectedSubtitle.language}.vtt`; + const delayKey = subtitleDelay.toFixed(1).replace('.', 'p'); + const vttFilename = `${sanitizedTitle}_${selectedSubtitle.language}_d${delayKey}.vtt`; const srtPath = `${subtitlesDir}/${srtFilename}`; const vttPath = `${subtitlesDir}/${vttFilename}`; @@ -316,13 +296,9 @@ const MediaPlayer: React.FC = ({ } setSubtitleContent(srtContent); - - const vttExists = await RNFS.exists(vttPath); - if (!vttExists) { - const vttContent = convertSrtToVtt(srtContent); - await RNFS.writeFile(vttPath, vttContent, 'utf8'); - } - + + const vttContent = convertSrtToVtt(srtContent, subtitleDelay); + await RNFS.writeFile(vttPath, vttContent, 'utf8'); setSubtitleVttPath(`file://${vttPath}`); } catch { @@ -332,7 +308,7 @@ const MediaPlayer: React.FC = ({ }; downloadAndSaveSubtitle(); - }, [selectedSubtitle, contentId, season, episode]); + }, [selectedSubtitle, contentId, season, episode, subtitleDelay]); const videoContainerStyle = useMemo(() => ({ @@ -390,12 +366,15 @@ const MediaPlayer: React.FC = ({ const videoSource = useMemo(() => { const uri = playbackUrl; - const isCached = uri.startsWith('file://'); - return { + const useStreamHeaders = + uri.includes('.m3u8') || + (!uri.startsWith('file://') && !uri.startsWith('/')); + const base = { uri, textTracks, - ...(isCached ? {} : { headers: VIDEO_STREAM_HEADERS }), + ...(useStreamHeaders ? { headers: VIDEO_STREAM_HEADERS } : {}), }; + return uri.includes('.m3u8') ? { ...base, type: 'm3u8' as const } : base; }, [playbackUrl, textTracks]); if (!videoUrl) { @@ -416,7 +395,7 @@ const MediaPlayer: React.FC = ({ Buffering ahead… )} - {playbackUrl && typeof playbackUrl === 'string' && playbackUrl.trim() !== '' ? ( + {!isCacheLoading && playbackUrl && typeof playbackUrl === 'string' && playbackUrl.trim() !== '' ? (