import React, { cloneElement, useCallback, useEffect, useRef, useState, } from 'react'; import { Animated, GestureResponderEvent, Image, StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle, } from 'react-native'; import Video, { OnLoadData, OnProgressData, VideoProperties, } from 'react-native-video'; import playImage from '../../Assets/play.png'; import pauseImage from '../../Assets/pause.png'; import enterFullscreenImage from '../../Assets/enter-fullscreen.png'; import safevideoLogoImage from '../../Assets/safevideo-logo.png'; import exitFullscreenImage from '../../Assets/exit-fullscreen.png'; import qualityImage from '../../Assets/quality.png'; import videoSpeedImage from '../../Assets/video-speed.png'; import optionsImage from '../../Assets/options.png'; import closeImage from '../../Assets/close.png'; import checkImage from '../../Assets/check.png'; import ProgressBar from './ProgressBar'; import OptionsModal from './OptionsModal'; import OptionItem from './OptionsModal/OptionItem'; import Loading from './Loading'; import { CastButton, CastState, useMediaStatus, useCastState, useRemoteMediaClient, MediaPlayerState, useStreamPosition, } from 'react-native-google-cast'; import MusicControl, { Command } from 'react-native-music-control'; interface ISource { uri: string; quality: number | 'auto'; } type IOption = 'quality' | 'rate'; interface SafeVideoPlayerProps { title?: string; artwork?: string; artist?: string; castId?: string; progressBarColor?: string; textColor?: string; backgroundColor?: string; onEnterFullscreen?: () => void; onExitFullscreen?: () => void; containerStyle?: StyleProp; controlsStyle?: StyleProp; onSeekStart?: () => void; onSeekEnd?: () => void; source?: any; startAt?: number; menuOption?: any | any[]; disableFullscreen?: boolean; disableCloseButton?: boolean; disableCast?: boolean; onRequestClose?: () => void; disableOptions?: boolean | IOption[]; playOnStart?: boolean; playInBackground?: boolean; defaultQuality?: number | 'auto'; onQualityChange?: (quality: number | 'auto') => void; } const CONTROLS_DISPLAY_TIME = 4000; const SafeVideoPlayer = ({ title, artwork, artist, castId, progressBarColor, textColor, backgroundColor, onEnterFullscreen, onExitFullscreen, playInBackground, containerStyle, controlsStyle, onSeekStart, onSeekEnd, onProgress, source, startAt = 0, menuOption, playOnStart, disableFullscreen, disableOptions, disableCloseButton, disableCast, onRequestClose, defaultQuality = 'auto', onQualityChange, ...videoProps }: VideoProperties & SafeVideoPlayerProps) => { const [playing, setPlaying] = useState(playOnStart || false); const [rate, setRate] = useState(1); const [loading, setLoading] = useState(true); const [timeoutId, setTimeoutId] = useState(); const [videoInfo, setVideoInfo] = useState({ currentTime: 0, duration: 0 }); const [fullscreen, setFullscreen] = useState(false); const [controlsEnabled, setControlsEnabled] = useState(true); const [showingSettings, setShowingSettings] = useState(false); const [showingSpeedOptions, setShowingSpeedOptions] = useState(false); const [showingQualityOptions, setShowingQualityOptions] = useState(false); const [qualitySources, setQualitySources] = useState([]); const [_disableOptions] = useState( Array.isArray(disableOptions) ? disableOptions.reduce( (previousValue, currentValue) => ({ ...previousValue, [currentValue]: true, }), {} as any ) : disableOptions ); const castState = useCastState(); const remoteMediaClient = useRemoteMediaClient(); const streamPosition = useStreamPosition(); const mediaStatus = useMediaStatus(); const videoRef = useRef(null); const fadeAnim = useRef(new Animated.Value(1)).current; const [_source, setSource] = useState({ uri: source.uri, quality: 'auto', }); useEffect(() => { if (!disableCast && remoteMediaClient) { switch (castState) { case CastState.CONNECTED: remoteMediaClient.getMediaStatus().then((_mediaStatus) => { if (!castId || _mediaStatus?.mediaInfo.contentId !== castId) { remoteMediaClient.loadMedia({ autoplay: true, startTime: videoInfo.currentTime || startAt, playbackRate: rate, mediaInfo: { contentId: castId, contentUrl: source.uri, contentType: 'application/x-mpegURL', }, }); if (playInBackground) { MusicControl.setNowPlaying({ title, artwork, artist, duration: videoInfo.duration || 0, isLiveStream: false, }); MusicControl.updatePlayback({ state: MusicControl.STATE_PLAYING, }); } } }); break; case CastState.NOT_CONNECTED: videoRef.current.seek(videoInfo.currentTime); break; default: break; } } }, [ disableCast, castState, remoteMediaClient, artist, artwork, castId, playInBackground, rate, source.uri, startAt, title, videoInfo.currentTime, videoInfo.duration, ]); useEffect(() => { if (!disableCast && streamPosition) { setVideoInfo({ duration: videoInfo.duration, currentTime: streamPosition, }); if (playInBackground) { MusicControl.updatePlayback({ elapsedTime: streamPosition, }); } } }, [disableCast, videoInfo.duration, streamPosition, playInBackground]); useEffect(() => { if (!disableCast && mediaStatus) { setPlaying(mediaStatus?.playerState === MediaPlayerState.PLAYING); setLoading( mediaStatus?.playerState === MediaPlayerState.LOADING || mediaStatus?.playerState === MediaPlayerState.BUFFERING ); setRate(mediaStatus.playbackRate); } }, [disableCast, mediaStatus]); useEffect(() => { fetch(source.uri) .then((response) => response.text()) .then((playList) => { const lines = playList.split('\n').slice(2); const resolutions = lines .filter((line, index) => index % 2 === 0 && line) .map((quality) => quality.slice(quality.indexOf('RESOLUTION=') + 11)); const uris = lines.filter((_, index) => index % 2 !== 0); const _qualitySources = resolutions .map((resolution, index) => ({ uri: uris[index], quality: parseInt( resolution.slice(resolution.indexOf('x') + 1), 10 ), })) .sort((a, b) => b.quality - a.quality); setQualitySources([ { uri: source.uri, quality: 'auto', }, ..._qualitySources, ]); const defaultQualitySource = _qualitySources.find( (_qualitySource) => _qualitySource.quality === defaultQuality ); if (defaultQualitySource) { setSource(defaultQualitySource); } }); }, [defaultQuality, source.uri]); const play = useCallback( (event?: GestureResponderEvent) => { if (remoteMediaClient && !disableCast) { remoteMediaClient.play(); } setPlaying(true); if (playInBackground) { if (event) { MusicControl.setNowPlaying({ title, artwork, artist, duration: videoInfo.duration || 0, isLiveStream: false, }); } MusicControl.updatePlayback({ state: MusicControl.STATE_PLAYING, }); } }, [ artist, artwork, disableCast, playInBackground, remoteMediaClient, title, videoInfo.duration, ] ); const pause = useCallback(() => { if (remoteMediaClient && !disableCast) { remoteMediaClient.pause(); } setPlaying(false); if (playInBackground) { MusicControl.updatePlayback({ state: MusicControl.STATE_PAUSED, }); } }, [disableCast, playInBackground, remoteMediaClient]); useEffect(() => { if (playInBackground) { MusicControl.enableBackgroundMode(true); MusicControl.enableControl('play', true); MusicControl.enableControl('pause', true); MusicControl.enableControl('stop', false); MusicControl.enableControl('nextTrack', false); MusicControl.enableControl('previousTrack', false); MusicControl.enableControl('changePlaybackPosition', false); MusicControl.enableControl('seekForward', false); // iOS only MusicControl.enableControl('seekBackward', false); // iOS only MusicControl.enableControl('seek', false); // Android only MusicControl.enableControl('skipBackward', false, { interval: 15 }); MusicControl.enableControl('skipForward', false, { interval: 30 }); MusicControl.enableControl('setRating', false); // Android only MusicControl.enableControl('volume', false); // Android only. Only affected when remoteVolume is enabled MusicControl.enableControl('remoteVolume', false); // Android only MusicControl.enableControl('enableLanguageOption', false); // iOS only MusicControl.enableControl('disableLanguageOption', false); // iOS only MusicControl.on(Command.play, () => { play(); }); MusicControl.on(Command.pause, () => { pause(); }); } return () => { if (playInBackground) { MusicControl.stopControl(); } }; }, [remoteMediaClient, play, pause, playInBackground]); const setVideoRate = (_rate: number) => () => { if (remoteMediaClient && !disableCast) { remoteMediaClient.setPlaybackRate(_rate); } setRate(_rate); }; const setVideoQuality = (quality: number | 'auto') => () => { const qualitySource = qualitySources.find( (_qualitySource) => _qualitySource.quality === quality ); if (qualitySource) { setSource(qualitySource); onQualityChange?.(qualitySource.quality); } }; const enterFullscreen = () => { setFullscreen(true); onEnterFullscreen && onEnterFullscreen(); }; const exitFullscreen = () => { setFullscreen(false); onExitFullscreen && onExitFullscreen(); }; const onTouchStart = () => { clearTimeout(timeoutId); fadeControls(true); }; const onTouchEnd = () => { setTimeoutId( setTimeout(() => { fadeControls(false); }, CONTROLS_DISPLAY_TIME) ); }; const fadeControls = (fadeIn: boolean) => { Animated.timing(fadeAnim, { toValue: fadeIn ? 1 : 0, duration: 200, useNativeDriver: true, }).start(); setControlsEnabled(fadeIn); }; const onLoadStart = () => { setLoading(true); }; const onLoad = (event: OnLoadData) => { setVideoInfo({ currentTime: startAt, duration: event.duration, }); videoRef.current.seek(startAt); setLoading(false); }; const onVideoProgress = (data: OnProgressData) => { setVideoInfo({ ...videoInfo, currentTime: data.currentTime, }); onProgress && onProgress(data); if (playInBackground) { MusicControl.updatePlayback({ elapsedTime: data.currentTime, }); } }; const onProgressTouchStart = () => { clearTimeout(timeoutId); setPlaying(false); onSeekStart && onSeekStart(); }; const onSeek = (seekTo: number) => { if (remoteMediaClient && !disableCast) { remoteMediaClient.seek({ position: seekTo }); } videoRef.current.seek(seekTo); setPlaying(true); onSeekEnd && onSeekEnd(); }; const showOptions = () => { setShowingSettings(true); }; const hideOptions = () => { setShowingSettings(false); }; const showSpeedOptions = () => { hideOptions(); setShowingSpeedOptions(true); }; const hideSpeedOptions = () => { setShowingSpeedOptions(false); }; const showQualityOptions = () => { hideOptions(); setShowingQualityOptions(true); }; const hideQualityOptions = () => { setShowingQualityOptions(false); }; const formatTime = (seconds: number) => { const date = new Date(0); date.setSeconds(seconds); let timeString = date.toISOString().substr(11, 8); if (seconds < 3600) { timeString = timeString.replace('00:', ''); } return timeString; }; return ( ); }; const styles = StyleSheet.create({ controls: { position: 'absolute', flex: 1, width: '100%', height: '100%', }, controlsContent: { flex: 1, }, backdrop: { position: 'absolute', flex: 1, width: '100%', height: '100%', backgroundColor: '#000', opacity: 0.7, }, header: { flex: 0, padding: 16, flexDirection: 'row', alignItems: 'center', }, videoTitle: { flex: 1, color: '#fff', fontSize: 16, }, headerActions: { flex: 0, paddingLeft: 8, flexDirection: 'row', }, closeIcon: { width: 15, height: 15, marginRight: 16, }, castButton: { width: 24, height: 24, tintColor: '#fff', marginRight: 16, }, optionsIcon: { width: 20, height: 20, }, body: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 16, }, player: { flex: 1, }, playPauseIcon: { width: 50, height: 50, }, footer: { padding: 16, flex: 0, width: '100%', }, footerActions: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, }, footerActionsContent: { flexDirection: 'row', }, timer: { color: '#fff', fontSize: 12, }, fullscreenIcon: { width: 15, height: 15, marginLeft: 15, }, safevideoLogo: { width: 76, height: 16, }, }); export default SafeVideoPlayer;