mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 12:00:33 +00:00
Added ExoPlayer as primary for better hardwre decoder support and MPV as fallback
This commit is contained in:
parent
2d97cad1dc
commit
6e2ddd2dda
12 changed files with 3428 additions and 386 deletions
|
|
@ -1,8 +1,41 @@
|
|||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { Platform, Animated, TouchableWithoutFeedback, View } from 'react-native';
|
||||
import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
|
||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||
|
||||
// Subtitle style configuration interface - matches ExoPlayer's SubtitleStyle
|
||||
export interface SubtitleStyleConfig {
|
||||
// Font size in SP (scale-independent pixels) for subtitle text
|
||||
// Default: -1 (uses system default)
|
||||
fontSize?: number;
|
||||
|
||||
// Padding values in pixels
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
|
||||
// Opacity of subtitles (0.0 to 1.0)
|
||||
// 0 = hidden, 1 = fully visible
|
||||
opacity?: number;
|
||||
|
||||
// Whether subtitles should follow video position when video is resized
|
||||
// true = subtitles stay within video bounds
|
||||
// false = subtitles can extend beyond video bounds
|
||||
subtitlesFollowVideo?: boolean;
|
||||
}
|
||||
|
||||
// Default subtitle style configuration
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
fontSize: 18,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 60,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
opacity: 1,
|
||||
subtitlesFollowVideo: true,
|
||||
};
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
headers?: { [key: string]: string };
|
||||
|
|
@ -12,6 +45,8 @@ interface VideoPlayerProps {
|
|||
selectedAudioTrack?: SelectedTrack;
|
||||
selectedTextTrack?: SelectedTrack;
|
||||
resizeMode?: ResizeMode;
|
||||
// Subtitle customization - pass custom subtitle styling
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
|
||||
onLoad?: (data: { duration: number }) => void;
|
||||
onError?: (error: any) => void;
|
||||
|
|
@ -29,6 +64,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
resizeMode = 'contain' as ResizeMode,
|
||||
subtitleStyle: customSubtitleStyle,
|
||||
onProgress,
|
||||
onLoad,
|
||||
onError,
|
||||
|
|
@ -41,6 +77,12 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [lastSeekTime, setLastSeekTime] = useState<number>(0);
|
||||
|
||||
// Merge custom subtitle style with defaults
|
||||
const subtitleStyle = useMemo(() => ({
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
...customSubtitleStyle,
|
||||
}), [customSubtitleStyle]);
|
||||
|
||||
// Enable immersive mode when video player mounts, disable when it unmounts
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
|
|
@ -132,13 +174,21 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
rate={1.0}
|
||||
repeat={false}
|
||||
reportBandwidth={true}
|
||||
textTracks={[]}
|
||||
useTextureView={false}
|
||||
useTextureView={true}
|
||||
disableFocus={false}
|
||||
minLoadRetryCount={3}
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
hideShutterView={false}
|
||||
shutterColor="#000000"
|
||||
subtitleStyle={{
|
||||
fontSize: subtitleStyle.fontSize,
|
||||
paddingTop: subtitleStyle.paddingTop,
|
||||
paddingBottom: subtitleStyle.paddingBottom,
|
||||
paddingLeft: subtitleStyle.paddingLeft,
|
||||
paddingRight: subtitleStyle.paddingRight,
|
||||
opacity: subtitleStyle.opacity,
|
||||
subtitlesFollowVideo: subtitleStyle.subtitlesFollowVideo,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
635
ios/Podfile.lock
635
ios/Podfile.lock
File diff suppressed because it is too large
Load diff
|
|
@ -2182,6 +2182,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (textRendererIndex != C.INDEX_UNSET) {
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
boolean trackFound = false;
|
||||
int cumulativeIndex = 0; // Track cumulative index across all groups
|
||||
|
||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
TrackGroup group = groups.get(groupIndex);
|
||||
|
|
@ -2195,7 +2196,8 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
isMatch = true;
|
||||
} else if ("index".equals(type)) {
|
||||
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
if (targetIndex == trackIndex) {
|
||||
// Use cumulative index to match getTextTrackInfo() behavior
|
||||
if (targetIndex == cumulativeIndex) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2207,6 +2209,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
trackFound = true;
|
||||
break;
|
||||
}
|
||||
cumulativeIndex++; // Increment after each track
|
||||
}
|
||||
if (trackFound)
|
||||
break;
|
||||
|
|
|
|||
2692
patches/react-native-video+6.18.0.patch
Normal file
2692
patches/react-native-video+6.18.0.patch
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { View, StyleSheet, Platform, Animated } from 'react-native';
|
||||
import { View, StyleSheet, Platform, Animated, ToastAndroid } from 'react-native';
|
||||
import { toast } from '@backpackapp-io/react-native-toast';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
|
|
@ -78,6 +78,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
const videoRef = useRef<any>(null);
|
||||
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
|
||||
const exoPlayerRef = useRef<any>(null);
|
||||
const pinchRef = useRef(null);
|
||||
const tracksHook = usePlayerTracks();
|
||||
|
||||
|
|
@ -92,6 +93,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// State to force unmount VideoSurface during stream transitions
|
||||
const [isTransitioningStream, setIsTransitioningStream] = useState(false);
|
||||
|
||||
// Dual video engine state: ExoPlayer primary, MPV fallback
|
||||
const [useExoPlayer, setUseExoPlayer] = useState(true);
|
||||
const hasExoPlayerFailed = useRef(false);
|
||||
|
||||
// Subtitle addon state
|
||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
|
||||
|
|
@ -131,7 +136,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
playerState.currentTime,
|
||||
playerState.duration,
|
||||
playerState.isSeeking,
|
||||
playerState.isMounted
|
||||
playerState.isMounted,
|
||||
exoPlayerRef,
|
||||
useExoPlayer
|
||||
);
|
||||
|
||||
const traktAutosync = useTraktAutosync({
|
||||
|
|
@ -327,6 +334,16 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
|
||||
}, [navigation]);
|
||||
|
||||
// Handle codec errors from ExoPlayer - silently switch to MPV
|
||||
const handleCodecError = useCallback(() => {
|
||||
if (!hasExoPlayerFailed.current) {
|
||||
hasExoPlayerFailed.current = true;
|
||||
logger.warn('[AndroidVideoPlayer] ExoPlayer codec error detected, switching to MPV silently');
|
||||
ToastAndroid.show('Switching to MPV due to unsupported codec', ToastAndroid.SHORT);
|
||||
setUseExoPlayer(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectStream = async (newStream: any) => {
|
||||
if (newStream.url === currentStreamUrl) {
|
||||
modals.setShowSourcesModal(false);
|
||||
|
|
@ -485,6 +502,13 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
else playerState.setResizeMode('contain');
|
||||
}, [playerState.resizeMode]);
|
||||
|
||||
// Memoize selectedTextTrack to prevent unnecessary re-renders
|
||||
const memoizedSelectedTextTrack = useMemo(() => {
|
||||
return tracksHook.selectedTextTrack === -1
|
||||
? { type: 'disabled' as const }
|
||||
: { type: 'index' as const, value: tracksHook.selectedTextTrack };
|
||||
}, [tracksHook.selectedTextTrack]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, {
|
||||
width: playerState.screenDimensions.width,
|
||||
|
|
@ -566,12 +590,18 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}}
|
||||
mpvPlayerRef={mpvPlayerRef}
|
||||
exoPlayerRef={exoPlayerRef}
|
||||
pinchRef={pinchRef}
|
||||
onPinchGestureEvent={() => { }}
|
||||
onPinchHandlerStateChange={() => { }}
|
||||
screenDimensions={playerState.screenDimensions}
|
||||
decoderMode={settings.decoderMode}
|
||||
gpuMode={settings.gpuMode}
|
||||
// Dual video engine props
|
||||
useExoPlayer={useExoPlayer}
|
||||
onCodecError={handleCodecError}
|
||||
selectedAudioTrack={tracksHook.selectedAudioTrack as any || undefined}
|
||||
selectedTextTrack={memoizedSelectedTextTrack as any}
|
||||
// Subtitle Styling - pass to MPV for built-in subtitle customization
|
||||
// MPV uses different scaling than React Native, so we apply conversion factors:
|
||||
// - Font size: MPV needs ~1.5x larger values (MPV's sub-font-size vs RN fontSize)
|
||||
|
|
@ -669,7 +699,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}}
|
||||
buffered={playerState.buffered}
|
||||
formatTime={formatTime}
|
||||
playerBackend={'MPV'}
|
||||
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
|
||||
/>
|
||||
|
||||
<SpeedActivatedOverlay
|
||||
|
|
@ -764,16 +794,19 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
selectedTextTrack={tracksHook.computedSelectedTextTrack}
|
||||
useCustomSubtitles={useCustomSubtitles}
|
||||
isKsPlayerActive={true}
|
||||
useExoPlayer={useExoPlayer}
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleBackground={subtitleBackground}
|
||||
fetchAvailableSubtitles={fetchAvailableSubtitles}
|
||||
loadWyzieSubtitle={loadWyzieSubtitle}
|
||||
selectTextTrack={(trackId) => {
|
||||
tracksHook.setSelectedTextTrack(trackId);
|
||||
// Actually tell MPV to switch the subtitle track
|
||||
if (mpvPlayerRef.current) {
|
||||
// For MPV, manually switch the subtitle track
|
||||
if (!useExoPlayer && mpvPlayerRef.current) {
|
||||
mpvPlayerRef.current.setSubtitleTrack(trackId);
|
||||
}
|
||||
// For ExoPlayer, the selectedTextTrack prop will be updated via memoizedSelectedTextTrack
|
||||
// which triggers a re-render with the new track selection
|
||||
// Disable custom subtitles when selecting built-in track
|
||||
setUseCustomSubtitles(false);
|
||||
modals.setShowSubtitleModal(false);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,35 @@
|
|||
import React, { useCallback, memo } from 'react';
|
||||
import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
|
||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
|
||||
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
|
||||
import { styles } from '../../utils/playerStyles';
|
||||
import { ResizeModeType } from '../../utils/playerTypes';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
// Codec error patterns that indicate we should fallback to MPV
|
||||
const CODEC_ERROR_PATTERNS = [
|
||||
'exceeds_capabilities',
|
||||
'no_exceeds_capabilities',
|
||||
'decoder_exception',
|
||||
'decoder.*error',
|
||||
'codec.*error',
|
||||
'unsupported.*codec',
|
||||
'mediacodec.*exception',
|
||||
'omx.*error',
|
||||
'dolby.*vision',
|
||||
'hevc.*error',
|
||||
'no suitable decoder',
|
||||
'decoder initialization failed',
|
||||
'format.no_decoder',
|
||||
'no_decoder',
|
||||
'decoding_failed',
|
||||
'error_code_decoding',
|
||||
'exoplaybackexception',
|
||||
'mediacodecvideodecoder',
|
||||
'mediacodecvideodecoderexception',
|
||||
'decoder failed',
|
||||
];
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
processedStreamUrl: string;
|
||||
|
|
@ -25,6 +51,7 @@ interface VideoSurfaceProps {
|
|||
|
||||
// Refs
|
||||
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
|
||||
exoPlayerRef?: React.RefObject<VideoRef>;
|
||||
pinchRef: any;
|
||||
|
||||
// Handlers
|
||||
|
|
@ -32,8 +59,16 @@ interface VideoSurfaceProps {
|
|||
onPinchHandlerStateChange: any;
|
||||
screenDimensions: { width: number, height: number };
|
||||
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
|
||||
selectedAudioTrack?: SelectedTrack;
|
||||
selectedTextTrack?: SelectedTrack;
|
||||
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
|
||||
gpuMode?: 'gpu' | 'gpu-next';
|
||||
|
||||
// Dual Engine Props
|
||||
useExoPlayer?: boolean;
|
||||
onCodecError?: () => void;
|
||||
onEngineChange?: (engine: 'exoplayer' | 'mpv') => void;
|
||||
|
||||
// Subtitle Styling
|
||||
subtitleSize?: number;
|
||||
subtitleColor?: string;
|
||||
|
|
@ -46,6 +81,15 @@ interface VideoSurfaceProps {
|
|||
subtitleAlignment?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Helper function to check if error is a codec error
|
||||
const isCodecError = (errorString: string): boolean => {
|
||||
const lowerError = errorString.toLowerCase();
|
||||
return CODEC_ERROR_PATTERNS.some(pattern => {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(lowerError);
|
||||
});
|
||||
};
|
||||
|
||||
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||
processedStreamUrl,
|
||||
headers,
|
||||
|
|
@ -62,13 +106,20 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onError,
|
||||
onBuffer,
|
||||
mpvPlayerRef,
|
||||
exoPlayerRef,
|
||||
pinchRef,
|
||||
onPinchGestureEvent,
|
||||
onPinchHandlerStateChange,
|
||||
screenDimensions,
|
||||
onTracksChanged,
|
||||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
decoderMode,
|
||||
gpuMode,
|
||||
// Dual Engine
|
||||
useExoPlayer = true,
|
||||
onCodecError,
|
||||
onEngineChange,
|
||||
// Subtitle Styling
|
||||
subtitleSize,
|
||||
subtitleColor,
|
||||
|
|
@ -83,10 +134,9 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
// Use the actual stream URL
|
||||
const streamUrl = currentStreamUrl || processedStreamUrl;
|
||||
|
||||
// Debug logging removed to prevent console spam
|
||||
|
||||
const handleLoad = (data: { duration: number; width: number; height: number }) => {
|
||||
console.log('[VideoSurface] onLoad received:', data);
|
||||
// ========== MPV Handlers ==========
|
||||
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
|
||||
console.log('[VideoSurface] MPV onLoad received:', data);
|
||||
onLoad({
|
||||
duration: data.duration,
|
||||
naturalSize: {
|
||||
|
|
@ -96,15 +146,15 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleProgress = (data: { currentTime: number; duration: number }) => {
|
||||
const handleMpvProgress = (data: { currentTime: number; duration: number }) => {
|
||||
onProgress({
|
||||
currentTime: data.currentTime,
|
||||
playableDuration: data.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (error: { error: string }) => {
|
||||
console.log('[VideoSurface] onError received:', error);
|
||||
const handleMpvError = (error: { error: string }) => {
|
||||
console.log('[VideoSurface] MPV onError received:', error);
|
||||
onError({
|
||||
error: {
|
||||
errorString: error.error,
|
||||
|
|
@ -112,44 +162,204 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
console.log('[VideoSurface] onEnd received');
|
||||
const handleMpvEnd = () => {
|
||||
console.log('[VideoSurface] MPV onEnd received');
|
||||
onEnd();
|
||||
};
|
||||
|
||||
// ========== ExoPlayer Handlers ==========
|
||||
const handleExoLoad = (data: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onLoad received:', data);
|
||||
console.log('[VideoSurface] ExoPlayer textTracks raw:', JSON.stringify(data.textTracks, null, 2));
|
||||
|
||||
// Extract track information
|
||||
const audioTracks = data.audioTracks?.map((t: any, i: number) => ({
|
||||
id: t.index ?? i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
})) ?? [];
|
||||
|
||||
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
|
||||
const track = {
|
||||
id: t.index ?? i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
};
|
||||
console.log('[VideoSurface] Mapped subtitle track:', track, 'original:', t);
|
||||
return track;
|
||||
}) ?? [];
|
||||
|
||||
if (onTracksChanged && (audioTracks.length > 0 || subtitleTracks.length > 0)) {
|
||||
onTracksChanged({ audioTracks, subtitleTracks });
|
||||
}
|
||||
|
||||
onLoad({
|
||||
duration: data.duration,
|
||||
naturalSize: data.naturalSize || { width: 1920, height: 1080 },
|
||||
audioTracks: data.audioTracks,
|
||||
textTracks: data.textTracks,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoProgress = (data: any) => {
|
||||
onProgress({
|
||||
currentTime: data.currentTime,
|
||||
playableDuration: data.playableDuration || data.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoError = (error: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onError received:', JSON.stringify(error, null, 2));
|
||||
|
||||
// Extract error string - try multiple paths
|
||||
let errorString = 'Unknown error';
|
||||
const errorParts: string[] = [];
|
||||
|
||||
if (typeof error?.error === 'string') {
|
||||
errorParts.push(error.error);
|
||||
}
|
||||
if (error?.error?.errorString) {
|
||||
errorParts.push(error.error.errorString);
|
||||
}
|
||||
if (error?.error?.errorCode) {
|
||||
errorParts.push(String(error.error.errorCode));
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
errorParts.push(error);
|
||||
}
|
||||
if (error?.nativeStackAndroid) {
|
||||
errorParts.push(error.nativeStackAndroid.join(' '));
|
||||
}
|
||||
if (error?.message) {
|
||||
errorParts.push(error.message);
|
||||
}
|
||||
|
||||
// Combine all error parts for comprehensive checking
|
||||
errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
|
||||
|
||||
console.log('[VideoSurface] Extracted error string:', errorString);
|
||||
console.log('[VideoSurface] isCodecError result:', isCodecError(errorString));
|
||||
|
||||
// Check if this is a codec error that should trigger fallback
|
||||
if (isCodecError(errorString)) {
|
||||
logger.warn('[VideoSurface] ExoPlayer codec error detected, triggering MPV fallback:', errorString);
|
||||
onCodecError?.();
|
||||
return; // Don't propagate codec errors - we're falling back silently
|
||||
}
|
||||
|
||||
// Non-codec errors should be propagated
|
||||
onError({
|
||||
error: {
|
||||
errorString: errorString,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExoBuffer = (data: any) => {
|
||||
onBuffer({ isBuffering: data.isBuffering });
|
||||
};
|
||||
|
||||
const handleExoEnd = () => {
|
||||
console.log('[VideoSurface] ExoPlayer onEnd received');
|
||||
onEnd();
|
||||
};
|
||||
|
||||
const handleExoSeek = (data: any) => {
|
||||
onSeek({ currentTime: data.currentTime });
|
||||
};
|
||||
|
||||
// Map ResizeModeType to react-native-video ResizeMode
|
||||
const getExoResizeMode = (): ResizeMode => {
|
||||
switch (resizeMode) {
|
||||
case 'cover':
|
||||
return ResizeMode.COVER;
|
||||
case 'stretch':
|
||||
return ResizeMode.STRETCH;
|
||||
case 'contain':
|
||||
default:
|
||||
return ResizeMode.CONTAIN;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.videoContainer, {
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}]}>
|
||||
{/* MPV Player - rendered at the bottom of the z-order */}
|
||||
<MpvPlayer
|
||||
ref={mpvPlayerRef}
|
||||
source={streamUrl}
|
||||
headers={headers}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
|
||||
style={localStyles.player}
|
||||
onLoad={handleLoad}
|
||||
onProgress={handleProgress}
|
||||
onEnd={handleEnd}
|
||||
onError={handleError}
|
||||
onTracksChanged={onTracksChanged}
|
||||
decoderMode={decoderMode}
|
||||
gpuMode={gpuMode}
|
||||
// Subtitle Styling
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleColor={subtitleColor}
|
||||
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
|
||||
subtitleBorderSize={subtitleBorderSize}
|
||||
subtitleBorderColor={subtitleBorderColor}
|
||||
subtitleShadowEnabled={subtitleShadowEnabled}
|
||||
subtitlePosition={subtitlePosition}
|
||||
subtitleDelay={subtitleDelay}
|
||||
subtitleAlignment={subtitleAlignment}
|
||||
/>
|
||||
{useExoPlayer ? (
|
||||
/* ExoPlayer via react-native-video */
|
||||
<Video
|
||||
ref={exoPlayerRef}
|
||||
source={{
|
||||
uri: streamUrl,
|
||||
headers: headers,
|
||||
}}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={getExoResizeMode()}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
style={localStyles.player}
|
||||
onLoad={handleExoLoad}
|
||||
onProgress={handleExoProgress}
|
||||
onEnd={handleExoEnd}
|
||||
onError={handleExoError}
|
||||
onBuffer={handleExoBuffer}
|
||||
onSeek={handleExoSeek}
|
||||
progressUpdateInterval={500}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
useTextureView={true}
|
||||
// Subtitle Styling for ExoPlayer
|
||||
// ExoPlayer supports: fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
|
||||
subtitleStyle={{
|
||||
// Convert MPV-scaled size back to ExoPlayer scale (~1.5x conversion was applied)
|
||||
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 18,
|
||||
paddingTop: 0,
|
||||
// Convert MPV position (0=top, 100=bottom) to paddingBottom
|
||||
// Higher MPV position = less padding from bottom
|
||||
paddingBottom: subtitlePosition ? Math.max(20, Math.round((100 - subtitlePosition) * 2)) : 60,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
// Opacity controls entire subtitle view visibility
|
||||
// Always keep text visible (opacity 1), background control is limited in ExoPlayer
|
||||
opacity: 1,
|
||||
subtitlesFollowVideo: false,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* MPV Player fallback */
|
||||
<MpvPlayer
|
||||
ref={mpvPlayerRef}
|
||||
source={streamUrl}
|
||||
headers={headers}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
|
||||
style={localStyles.player}
|
||||
onLoad={handleMpvLoad}
|
||||
onProgress={handleMpvProgress}
|
||||
onEnd={handleMpvEnd}
|
||||
onError={handleMpvError}
|
||||
onTracksChanged={onTracksChanged}
|
||||
decoderMode={decoderMode}
|
||||
gpuMode={gpuMode}
|
||||
// Subtitle Styling
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleColor={subtitleColor}
|
||||
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
|
||||
subtitleBorderSize={subtitleBorderSize}
|
||||
subtitleBorderColor={subtitleBorderColor}
|
||||
subtitleShadowEnabled={subtitleShadowEnabled}
|
||||
subtitlePosition={subtitlePosition}
|
||||
subtitleDelay={subtitleDelay}
|
||||
subtitleAlignment={subtitleAlignment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gesture overlay - transparent, on top of the player */}
|
||||
<PinchGestureHandler
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { VideoRef } from 'react-native-video';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
const DEBUG_MODE = true; // Temporarily enable for debugging seek
|
||||
|
|
@ -12,7 +13,10 @@ export const usePlayerControls = (
|
|||
currentTime: number,
|
||||
duration: number,
|
||||
isSeeking: React.MutableRefObject<boolean>,
|
||||
isMounted: React.MutableRefObject<boolean>
|
||||
isMounted: React.MutableRefObject<boolean>,
|
||||
// Dual engine support
|
||||
exoPlayerRef?: React.RefObject<VideoRef>,
|
||||
useExoPlayer?: boolean
|
||||
) => {
|
||||
// iOS seeking helpers
|
||||
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
|
||||
|
|
@ -28,12 +32,30 @@ export const usePlayerControls = (
|
|||
rawSeconds,
|
||||
timeInSeconds,
|
||||
hasMpvRef: !!mpvPlayerRef?.current,
|
||||
hasExoRef: !!exoPlayerRef?.current,
|
||||
useExoPlayer,
|
||||
duration,
|
||||
isSeeking: isSeeking.current
|
||||
});
|
||||
|
||||
// MPV Player
|
||||
if (mpvPlayerRef.current && duration > 0) {
|
||||
// ExoPlayer
|
||||
if (useExoPlayer && exoPlayerRef?.current && duration > 0) {
|
||||
console.log(`[usePlayerControls][ExoPlayer] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
exoPlayerRef.current.seek(timeInSeconds);
|
||||
|
||||
// Reset seeking flag after a delay
|
||||
setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
isSeeking.current = false;
|
||||
}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV Player (fallback or when useExoPlayer is false)
|
||||
if (mpvPlayerRef?.current && duration > 0) {
|
||||
console.log(`[usePlayerControls][MPV] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
|
@ -45,13 +67,16 @@ export const usePlayerControls = (
|
|||
isSeeking.current = false;
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
console.log('[usePlayerControls][MPV] Cannot seek - ref or duration invalid:', {
|
||||
hasRef: !!mpvPlayerRef?.current,
|
||||
duration
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [duration, paused, setPaused, mpvPlayerRef, isSeeking, isMounted]);
|
||||
|
||||
console.log('[usePlayerControls] Cannot seek - no valid ref:', {
|
||||
hasExoRef: !!exoPlayerRef?.current,
|
||||
hasMpvRef: !!mpvPlayerRef?.current,
|
||||
useExoPlayer,
|
||||
duration
|
||||
});
|
||||
}, [duration, paused, setPaused, mpvPlayerRef, exoPlayerRef, useExoPlayer, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
console.log('[usePlayerControls] skip called:', { seconds, currentTime, newTime: currentTime + seconds });
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { logger } from '../../../utils/logger';
|
|||
interface AudioTrackModalProps {
|
||||
showAudioModal: boolean;
|
||||
setShowAudioModal: (show: boolean) => void;
|
||||
ksAudioTracks: Array<{id: number, name: string, language?: string}>;
|
||||
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
|
||||
selectedAudioTrack: number | null;
|
||||
selectAudioTrack: (trackId: number) => void;
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
if (!showAudioModal) return null;
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} zIndex={9999}>
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
|
||||
{/* Backdrop matching SubtitleModal */}
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFill}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface EpisodeStreamsModalProps {
|
|||
metadata?: { id?: string; name?: string };
|
||||
}
|
||||
|
||||
const QualityBadge = ({ quality }: { quality: string | null }) => {
|
||||
const QualityBadge = ({ quality }: { quality: string | null | undefined }) => {
|
||||
if (!quality) return null;
|
||||
|
||||
const qualityNum = parseInt(quality);
|
||||
|
|
@ -140,7 +140,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
const sortedProviders = Object.entries(availableStreams);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} zIndex={10000}>
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
{/* Backdrop */}
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFill}
|
||||
|
|
@ -263,9 +263,9 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
)}
|
||||
|
||||
{hasErrors.length > 0 && (
|
||||
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
|
||||
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text>
|
||||
</View>
|
||||
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
|
||||
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface SourcesModalProps {
|
|||
isChangingSource?: boolean;
|
||||
}
|
||||
|
||||
const QualityBadge = ({ quality }: { quality: string | null }) => {
|
||||
const QualityBadge = ({ quality }: { quality: string | null | undefined }) => {
|
||||
if (!quality) return null;
|
||||
|
||||
const qualityNum = parseInt(quality);
|
||||
|
|
@ -85,7 +85,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} zIndex={10000}>
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
{/* Backdrop */}
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFill}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
|
|||
if (!showSpeedModal) return null;
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} zIndex={9999}>
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFill}
|
||||
activeOpacity={1}
|
||||
|
|
@ -85,7 +85,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
|
||||
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600'}}>Playback Speed</Text>
|
||||
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>Playback Speed</Text>
|
||||
</View>
|
||||
|
||||
{/* Speed Selection Row */}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface SubtitleModalsProps {
|
|||
selectedTextTrack: number;
|
||||
useCustomSubtitles: boolean;
|
||||
isKsPlayerActive?: boolean;
|
||||
// Whether ExoPlayer is being used (limits subtitle styling options)
|
||||
useExoPlayer?: boolean;
|
||||
subtitleSize: number;
|
||||
subtitleBackground: boolean;
|
||||
fetchAvailableSubtitles: () => void;
|
||||
|
|
@ -81,6 +83,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
subtitleSize, subtitleBackground, fetchAvailableSubtitles,
|
||||
loadWyzieSubtitle, selectTextTrack, increaseSubtitleSize,
|
||||
decreaseSubtitleSize, toggleSubtitleBackground, subtitleTextColor, setSubtitleTextColor,
|
||||
useExoPlayer = false,
|
||||
subtitleBgOpacity, setSubtitleBgOpacity, subtitleTextShadow, setSubtitleTextShadow,
|
||||
subtitleOutline, setSubtitleOutline, subtitleOutlineColor, setSubtitleOutlineColor,
|
||||
subtitleOutlineWidth, setSubtitleOutlineWidth, subtitleAlign, setSubtitleAlign,
|
||||
|
|
@ -96,6 +99,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
const isCompact = width < 360 || height < 640;
|
||||
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
|
||||
const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles;
|
||||
// ExoPlayer has limited styling support - hide unsupported options when using ExoPlayer with internal subs
|
||||
const isExoPlayerInternal = useExoPlayer && isUsingInternalSubtitle;
|
||||
const sectionPad = isCompact ? 12 : 16;
|
||||
const chipPadH = isCompact ? 8 : 12;
|
||||
const chipPadV = isCompact ? 6 : 8;
|
||||
|
|
@ -114,7 +119,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
if (!showSubtitleModal) return null;
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} zIndex={9999}>
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
|
||||
{/* Backdrop */}
|
||||
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||||
|
|
@ -182,7 +187,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
<TouchableOpacity
|
||||
key={sub.id}
|
||||
onPress={() => { setSelectedOnlineSubtitleId(sub.id); loadWyzieSubtitle(sub); }}
|
||||
style={{ padding: 5, paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', textAlignVertical: 'center' }}
|
||||
style={{ padding: 5, paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ marginLeft: 5, color: selectedOnlineSubtitleId === sub.id ? 'black' : 'white', fontWeight: '600' }}>{sub.display}</Text>
|
||||
|
|
@ -228,7 +233,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Presets */}
|
||||
{/* Quick Presets - Hidden for ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
|
||||
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
|
||||
|
|
@ -272,6 +278,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Core controls */}
|
||||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
|
||||
|
|
@ -296,6 +303,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Show Background - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
|
||||
|
|
@ -308,14 +317,17 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
<View style={{ width: 24, height: 24, backgroundColor: subtitleBackground ? 'black' : 'white', borderRadius: 12 }} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Advanced controls */}
|
||||
{/* Advanced controls - Limited for ExoPlayer */}
|
||||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Advanced</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? 'Position' : 'Advanced'}</Text>
|
||||
</View>
|
||||
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
|
||||
|
|
@ -327,6 +339,9 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Align - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
|
|
@ -337,6 +352,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
|
|
@ -351,6 +367,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
|
|
@ -365,6 +383,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
|
||||
|
|
@ -431,6 +450,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Timing Offset - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ marginTop: 4 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
|
||||
|
|
@ -448,6 +469,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ alignItems: 'flex-end', marginTop: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue