nexle-tvguide-lib
Version:
TV guide library for Android TV
554 lines (506 loc) • 23.2 kB
JavaScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
View,
StyleSheet,
Animated,
ScrollView,
FlatList,
TVEventHandler,
} from 'react-native';
import PropTypes from 'prop-types';
import { HeaderDatePannel, ProgramLine, ChannelItem, HeaderTimeCell, LoadingIndicator } from './components';
import { TV_GUIDE_CONSTANTS } from './constants';
import {
generateTimelineData,
getDefaultActiveProgramIndex,
getDurationFromDateStart,
getDurationToDateEnd,
compareTwoDates,
} from './util';
const today = new Date();
var timelineTimeOutRef, programsTimeOutRef;
var displayedTimeline = [];
var currentDateDisplay = new Date();
var timelineData = [];
var lastProgramFocus;
var viewableProgramListEndIndex = 0;
var viewableProgramListStartIndex = 0;
var previousDateLoadingFlag = 0;
var nextDateLoadingFlag = 0;
var programListSafe = [];
var loadingTimeoutRef;
var defaultFocusTimeoutRef;
var activeProgram;
const getTimeIndicatorOffset = (timelineData, timelineCellWidth) => {
if (timelineData.length === 0) return 0;
const now = new Date();
return Math.abs(now.getTime() - timelineData[0].start) / TV_GUIDE_CONSTANTS.HALF_HOUR_DURATION * timelineCellWidth;
};
function TVGuideComponent(props) {
const {
channeList = [],
programList = [],
// onReachingEndChannel,
currentDate,
onDateChange,
onProgramChange,
onLoadingMoreProgramsByTime,
programStylesColors,
programContainerStyles,
timeIndicatorStyles,
tvGuideWidth,
tvGuideHeight,
timeLineHeaderHeight,
numberOfChannelsDisplayed,
numberOfTimelineCellDisplayed,
channelListWidth,
numberOfFutureDays,
numberOfPastDays
} = props;
const scrollAnimation = useRef(new Animated.Value(0));
const timelineHeaderRef = useRef(null);
const horizontalScrollRef = useRef(null);
const channelListRef = useRef(null);
const programListRef = useRef(null);
const [timeIndicatorOffset, setTimeIndicatorOffset] = useState(null);
const tvEventHandler = new TVEventHandler();
const visibleTimeIndicator = compareTwoDates(currentDateDisplay, today);
const [visibleLoadingIndicator, setVisibleLoadingIndicator] = useState(false);
const timeIndicatorStylesFlatten = useMemo(() => StyleSheet.flatten([styles.timeIndicator, timeIndicatorStyles]), [timeIndicatorStyles]);
const containerStylesFlattten = useMemo(() => StyleSheet.flatten([styles.container, { width: tvGuideWidth, height: tvGuideHeight }]), [tvGuideWidth, tvGuideHeight]);
const timelineCellWidth = (tvGuideWidth - channelListWidth) / numberOfTimelineCellDisplayed;
const programLineHeight = (tvGuideHeight - timeLineHeaderHeight) / numberOfChannelsDisplayed;
const programListContainerWidth = tvGuideWidth - channelListWidth;
const scrollHorizontal = useCallback(offsetX => {
if (!timelineHeaderRef || !horizontalScrollRef) return;
timelineTimeOutRef = setTimeout(() => {
timelineHeaderRef.current?.scrollToOffset({ animated: false, offset: offsetX });
}, 100);
programsTimeOutRef = setTimeout(() => {
horizontalScrollRef.current?.scrollTo({ x: offsetX, y: 0, animated: false });
}, 300);
}, []);
const scrollVertical = useCallback((lineIndex) => {
try {
if (!channelListRef || !programListRef) return;
const index = lineIndex > 0 ? lineIndex - 1 : lineIndex;
channelListRef.current?.scrollToIndex({
animated: true,
index: index,
});
programListRef.current?.scrollToIndex({
animated: true,
index: index,
});
} catch (e) {
console.log(e)
}
}, []);
const stopAnimationScroll = useCallback(() => {
scrollAnimation?.current.stopAnimation();
}, []);
const handleProgramFocus = (index, lineIndex, program) => {
activeProgram = { index, lineIndex, program };
if (typeof displayedTimeline !== 'object' || displayedTimeline.length === 0) return;
if (lineIndex > 0 && lineIndex === viewableProgramListEndIndex) {
scrollVertical(lineIndex);
}
if (lastProgramFocus.lineIndex > 0 && lineIndex >= viewableProgramListStartIndex && lineIndex < lastProgramFocus.lineIndex) {
scrollVertical(lineIndex);
}
const progamStartDate = program.startDateAdjusted;
const progamEndDate = program.endDateAdjusted;
const displayedTimelineLastItem = displayedTimeline[displayedTimeline.length - 1];
const displayedTimelineLastIndex = timelineData.findIndex(t => t.start === displayedTimelineLastItem.start);
let newDisplayedTimeline = [...displayedTimeline];
if (displayedTimeline && displayedTimeline.length === numberOfTimelineCellDisplayed) {
newDisplayedTimeline = [
...displayedTimeline,
{ ...timelineData[displayedTimelineLastIndex + 1] },
];
}
if (displayedTimeline && displayedTimeline.length === numberOfTimelineCellDisplayed + 2) {
const clonedDisplayedTimeline = [...displayedTimeline];
newDisplayedTimeline = clonedDisplayedTimeline.splice(1);
}
if (progamStartDate >= newDisplayedTimeline[0].start && progamEndDate <= newDisplayedTimeline[newDisplayedTimeline.length - 1].start) {
if (progamStartDate === timelineData[0].start) {
scrollHorizontal(0);
}
} else {
const offset = ((progamStartDate - timelineData[0].start) / TV_GUIDE_CONSTANTS.HALF_HOUR_DURATION) * timelineCellWidth;
scrollHorizontal(offset);
}
lastProgramFocus = { lineIndex, index };
onProgramChange({ program, lineIndex, index });
const durationToDateEnd = getDurationToDateEnd(currentDateDisplay, program.startDateAdjusted);
if (durationToDateEnd <= TV_GUIDE_CONSTANTS.REMAINING_TIME_TO_LOAD_MORE_PROGRAMS) {
onLoadingMoreProgramsByTime(TV_GUIDE_CONSTANTS.NEXT_DAY_EVENT_TYPE);
}
const durationFromDateStart = getDurationFromDateStart(currentDateDisplay, program.startDateAdjusted);
if (durationFromDateStart <= TV_GUIDE_CONSTANTS.REMAINING_TIME_TO_LOAD_MORE_PROGRAMS) {
onLoadingMoreProgramsByTime(TV_GUIDE_CONSTANTS.PREV_DAY_EVENT_TYPE);
}
};
const checkShouldChangeData = (typeCheck) => {
const { lineIndex, index } = lastProgramFocus;
if (typeof lastProgramFocus !== 'object' || !lastProgramFocus || !Array.isArray(programListSafe) || !programListSafe) return;
if (typeCheck === TV_GUIDE_CONSTANTS.PREV_DAY_EVENT_TYPE) {
if (index === 0 && activeProgram.lineIndex === lineIndex) {
previousDateLoadingFlag++;
if (previousDateLoadingFlag == 2) {
let prevDay = new Date(currentDateDisplay);
prevDay.setDate(prevDay.getDate() - 1);
if (Math.abs(parseInt(today.getDate()) - parseInt(prevDay.getDate())) <= numberOfPastDays) {
setVisibleLoadingIndicator(true);
onDateChange(prevDay);
resetGlobalVariables(typeCheck, prevDay);
}
}
if (index !== 0 || activeProgram.lineIndex !== lineIndex) {
previousDateLoadingFlag = 0;
}
};
}
if (typeCheck === TV_GUIDE_CONSTANTS.NEXT_DAY_EVENT_TYPE) {
if (index === (programListSafe[lineIndex].programs.length - 1) && activeProgram.lineIndex === lineIndex) {
nextDateLoadingFlag++;
if (nextDateLoadingFlag == 2) {
let nextDay = new Date(currentDateDisplay);
nextDay.setDate(nextDay.getDate() + 1);
if (Math.abs(parseInt(nextDay.getDate()) - parseInt(today.getDate())) <= numberOfFutureDays) {
setVisibleLoadingIndicator(true);
onDateChange(nextDay);
resetGlobalVariables(typeCheck, nextDay);
}
}
}
if ((index !== programListSafe[lineIndex].programs.length - 1) || activeProgram.lineIndex !== lineIndex) {
nextDateLoadingFlag = 0;
}
}
};
const resetGlobalVariables = useCallback((type, newDate) => {
if (type === TV_GUIDE_CONSTANTS.PREV_DAY_EVENT_TYPE) {
previousDateLoadingFlag = 0;
} else {
nextDateLoadingFlag = 0;
}
const { lineIndex } = lastProgramFocus;
lastProgramFocus = { lineIndex: lineIndex ? lineIndex : 0, index: 0 };
global.focusManager.setFocusForRoute('selectedAt', { lineNumber: lineIndex ? lineIndex : 0, index: 0 });
viewableProgramListEndIndex = 0;
viewableProgramListStartIndex = 0;
displayedTimeline = [];
currentDateDisplay = new Date(newDate);
timelineData = [...generateTimelineData(currentDateDisplay)];
}, []);
const enableTVEventHandler = () => {
tvEventHandler.enable(this, function (cmp, evt) {
if (evt && evt.eventType === TV_GUIDE_CONSTANTS.EVENT_KEYS.RIGHT) {
checkShouldChangeData(TV_GUIDE_CONSTANTS.NEXT_DAY_EVENT_TYPE);
} else if (evt && evt.eventType === TV_GUIDE_CONSTANTS.EVENT_KEYS.UP) {
//console.log(evt.eventType);
} else if (evt && evt.eventType === TV_GUIDE_CONSTANTS.EVENT_KEYS.LEFT) {
checkShouldChangeData(TV_GUIDE_CONSTANTS.PREV_DAY_EVENT_TYPE);
} else if (evt && evt.eventType === TV_GUIDE_CONSTANTS.EVENT_KEYS.DOWN) {
// console.log(evt.eventType);
}
});
};
const disableTVEventHandler = () => {
if (tvEventHandler) {
tvEventHandler.disable();
}
}
useEffect(() => {
if (channeList.length > 0 && programList.length > 0 && !lastProgramFocus) {
const defaultActiveProgramIndex = getDefaultActiveProgramIndex(programList[0].programs);
lastProgramFocus = { lineIndex: 0, index: defaultActiveProgramIndex };
global.focusManager.setFocusForRoute('selectedAt', { lineNumber: 0, index: defaultActiveProgramIndex });
handleProgramFocus({ index: defaultActiveProgramIndex, lineIndex: 0, program: programList[0].programs[defaultActiveProgramIndex] })
defaultFocusTimeoutRef = setTimeout(() => {
const offset = getTimeIndicatorOffset(timelineData, timelineCellWidth);
scrollHorizontal(offset);
}, 100);
}
if (programList && programList.length > 0) {
programListSafe = [...programList];
}
setVisibleLoadingIndicator(false);
}, [programList]);
const onViewableTimelineChanged = useCallback(({ viewableItems }) => {
const newViews = viewableItems.map(view => {
return { ...view.item };
});
displayedTimeline = [...newViews];
}, []);
useEffect(() => {
currentDateDisplay = new Date(currentDate);
}, [currentDate]);
useEffect(() => {
if (lastProgramFocus && lastProgramFocus.lineIndex && lastProgramFocus.lineIndex > 0) {
scrollVertical(lastProgramFocus.lineIndex)
}
timelineData = [...generateTimelineData(currentDateDisplay)];
if (timelineData.length > 0 && timeIndicatorOffset === null) {
setTimeIndicatorOffset(getTimeIndicatorOffset(timelineData, timelineCellWidth));
}
const interval = setInterval(() => {
const offset = getTimeIndicatorOffset(timelineData, timelineCellWidth);
setTimeIndicatorOffset(offset);
}, TV_GUIDE_CONSTANTS.TIME_INDICATOR_UPDATE_INTERVAL);
setVisibleLoadingIndicator(true);
loadingTimeoutRef = setTimeout(() => {
setVisibleLoadingIndicator(false);
}, TV_GUIDE_CONSTANTS.LOADING_INDICATOR_TIMEOUT);
enableTVEventHandler();
return () => {
clearInterval(interval);
clearTimeout(timelineTimeOutRef);
clearTimeout(programsTimeOutRef);
clearTimeout(loadingTimeoutRef);
clearTimeout(defaultFocusTimeoutRef);
disableTVEventHandler();
}
}, []);
const viewAbilityTimelineConfigRef = useRef([
{ onViewableItemsChanged: onViewableTimelineChanged },
]);
const viewTimelineConfigRef = React.useRef({
waitForInteraction: true,
viewAreaCoveragePercentThreshold: timelineCellWidth,
});
const onViewableProgramsChanged = useCallback(({ viewableItems }) => {
if (viewableItems && viewableItems.length > 0) {
viewableProgramListEndIndex = viewableItems[viewableItems.length - 1].index;
viewableProgramListStartIndex = viewableItems[0].index;
}
}, []);
const viewAbilityProgramListConfigRef = useRef([
{ onViewableItemsChanged: onViewableProgramsChanged },
]);
const viewProgramListConfigRef = React.useRef({
waitForInteraction: true,
viewAreaCoveragePercentThreshold: 70,
});
// const onEndReachedProgramsChannels = useCallback(() => {
// onReachingEndChannel();
// }, []);
const renderChannelItem = useCallback(({ item, index }) => {
return (
<ChannelItem
channel={item}
channelNumber={index}
tvGuideHeight={tvGuideHeight}
timeLineHeaderHeight={timeLineHeaderHeight}
numberOfChannelsDisplayed={numberOfChannelsDisplayed}
channelHeight={programLineHeight}
channelWidth={channelListWidth}
/>
);
}, []);
const renderTimeLineItem = useCallback(
({ item }) => <HeaderTimeCell time={item}
numberOfTimelineCellDisplayed={numberOfTimelineCellDisplayed}
timeLineItemWidth={timelineCellWidth}
/>,
[],
);
const getTimeLineKeyExtractor = useCallback(
(item, index) => `${item.start}-${index}`,
[],
);
const renderProgramLine = useCallback(({ item, index }) => {
const { channelExternalId, programs } = item;
return (
<ProgramLine
lastProgramFocus={lastProgramFocus}
currentDate={currentDateDisplay}
onFocus={handleProgramFocus}
lineNumber={index}
programs={programs}
channelExternalId={channelExternalId}
programStylesColors={programStylesColors}
programContainerStyles={programContainerStyles}
tvGuideWidth={tvGuideWidth}
tvGuideHeight={tvGuideHeight}
timeLineHeaderHeight={timeLineHeaderHeight}
numberOfChannelsDisplayed={numberOfChannelsDisplayed}
channelListWidth={channelListWidth}
numberOfTimelineCellDisplayed={numberOfTimelineCellDisplayed}
timelineCellWidth={timelineCellWidth}
/>
);
}, []);
const getProgramsKeyExtractor = useCallback(
item => `${item.channelExternalId}`,
[],
);
const getChannelsKeyExtractor = useCallback(item => `${item.id}`, []);
const getChannelsLayout = useCallback(
(data, index) => ({
length: programLineHeight,
offset: programLineHeight * index,
index,
}),
[],
);
const getProgramsLayout = useCallback(
(data, index) => ({
length: programLineHeight,
offset: programLineHeight * index,
index,
}),
[],
);
const getTimelinesLayout = useCallback(
(data, index) => ({
length: timelineCellWidth,
offset: timelineCellWidth * index,
index,
}),
[],
);
if (visibleLoadingIndicator) {
return (
<LoadingIndicator />
);
}
return (
<View style={containerStylesFlattten}>
<View style={styles.tvGuideContainer}>
<View style={styles.flexRow}>
<HeaderDatePannel currentDate={currentDate}
pannelWidth={channelListWidth}
pannelHeight={timeLineHeaderHeight}
/>
<FlatList
horizontal
removeClippedSubviews
legacyImplementation
ref={timelineHeaderRef}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
viewabilityConfig={viewTimelineConfigRef.current}
viewabilityConfigCallbackPairs={
viewAbilityTimelineConfigRef.current
}
data={timelineData}
renderItem={renderTimeLineItem}
keyExtractor={getTimeLineKeyExtractor}
contentContainerStyle={styles.contentScrollTimeStyle}
getItemLayout={getTimelinesLayout}
/>
</View>
<View style={styles.flexRow}>
<FlatList
removeClippedSubviews
legacyImplementation
scrollEnabled={false}
showsVerticalScrollIndicator={false}
initialNumToRender={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.INIT_NUM_TO_RENDER}
maxToRenderPerBatch={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.MAX_RENDER_PER_BATCH}
windowSize={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.WINDOW_SIZE}
ref={channelListRef}
data={channeList}
renderItem={renderChannelItem}
keyExtractor={getChannelsKeyExtractor}
contentContainerStyle={[styles.channelContentContainerStyle, { width: channelListWidth }]}
scrollEventThrottle={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.SCROLL_EVENT_THROTTLE}
getItemLayout={getChannelsLayout}
/>
<View style={{ width: programListContainerWidth }}>
<ScrollView
scrollEnabled={false}
horizontal
ref={horizontalScrollRef}
nestedScrollEnabled={false}
scrollEventThrottle={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.SCROLL_EVENT_THROTTLE}
onScrollBeginDrag={stopAnimationScroll}
onScrollEndDrag={stopAnimationScroll}
>
<FlatList
scrollEnabled={false}
legacyImplementation
removeClippedSubviews
ref={programListRef}
initialNumToRender={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.INIT_NUM_TO_RENDER}
maxToRenderPerBatch={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.MAX_RENDER_PER_BATCH}
windowSize={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.WINDOW_SIZE}
data={programList}
renderItem={renderProgramLine}
keyExtractor={getProgramsKeyExtractor}
// onEndReached={onEndReachedProgramsChannels}
onEndReachedThreshold={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.ON_END_REACHED_THRESHOLD}
contentContainerStyle={styles.programsContentContainerStyle}
scrollEventThrottle={TV_GUIDE_CONSTANTS.FLAT_LIST_CONFIG.SCROLL_EVENT_THROTTLE}
getItemLayout={getProgramsLayout}
viewabilityConfig={viewProgramListConfigRef.current}
viewabilityConfigCallbackPairs={
viewAbilityProgramListConfigRef.current
}
/>
{timeIndicatorOffset && visibleTimeIndicator && <View style={[timeIndicatorStylesFlatten, { left: timeIndicatorOffset }]} />}
</ScrollView>
</View>
</View>
</View>
</View >
);
}
TVGuideComponent.propTypes = {
channeList: PropTypes.array.isRequired,
programList: PropTypes.array.isRequired,
// onReachingEndChannel: PropTypes.func.isRequired,
onProgramChange: PropTypes.func.isRequired,
onLoadingMoreProgramsByTime: PropTypes.func.isRequired,
tvGuideWidth: PropTypes.number.isRequired,
tvGuideHeight: PropTypes.number.isRequired,
timeLineHeaderHeight: PropTypes.number,
numberOfChannelsDisplayed: PropTypes.number,
numberOfTimelineCellDisplayed: PropTypes.number,
channelListWidth: PropTypes.number,
numberOfFutureDays: PropTypes.number,
numberOfPastDays: PropTypes.number
};
TVGuideComponent.defaultProps = {
tvGuideWidth: TV_GUIDE_CONSTANTS.DEVICE_WIDTH,
tvGuideHeight: TV_GUIDE_CONSTANTS.DEVICE_HEIGHT * 3 / 4,
timeLineHeaderHeight: TV_GUIDE_CONSTANTS.HEADER_CELL_HEIGHT,
numberOfChannelsDisplayed: TV_GUIDE_CONSTANTS.NUMBER_OF_CHANNELS_DISPLAYED,
numberOfTimelineCellDisplayed: TV_GUIDE_CONSTANTS.NUMBER_OF_TIMELINE_CELLS_DISPLAYED,
channelListWidth: TV_GUIDE_CONSTANTS.CHANNEL_LIST_WIDTH,
numberOfFutureDays: TV_GUIDE_CONSTANTS.NUMBER_OF_FUTURE_DAYS,
numberOfPastDays: TV_GUIDE_CONSTANTS.NUMBER_OF_PAST_DAYS,
}
export default React.memo(TVGuideComponent);
const styles = StyleSheet.create({
container: {
backgroundColor: TV_GUIDE_CONSTANTS.THEME_STYLES.CONTAINER_BG_COLOR,
},
timeIndicator: {
width: 5,
height: '100%',
position: 'absolute',
zIndex: 99,
backgroundColor: TV_GUIDE_CONSTANTS.THEME_STYLES.TIME_INDICATOR_BG_COLOR,
},
flexRow: {
flexDirection: 'row',
},
tvGuideContainer: {
flex: 1,
},
channelContentContainerStyle: {
paddingBottom: 50,
backgroundColor: TV_GUIDE_CONSTANTS.THEME_STYLES.CONTAINER_BG_COLOR,
},
programsContentContainerStyle: {
backgroundColor: TV_GUIDE_CONSTANTS.THEME_STYLES.CONTAINER_BG_COLOR,
paddingBottom: 50,
},
contentScrollTimeStyle: {
backgroundColor: TV_GUIDE_CONSTANTS.THEME_STYLES.CONTAINER_BG_COLOR,
},
});