example/src/screens/TestPlan.tsx (611 lines of code) (raw):

import * as React from 'react'; import { parse } from 'yaml'; import IVSPlayer, { IVSPlayerProps, IVSPlayerRef, LogLevel, Quality, Source, } from 'amazon-ivs-react-native-player'; import { StyleSheet, View } from 'react-native'; import { Button, TextInput, Chip, Subheading, Text, ToggleButton, IconButton, } from 'react-native-paper'; import { ScrollView } from 'react-native-gesture-handler'; import { proxy, useSnapshot } from 'valtio'; type PlanProps = Record<string, any>; enum PlanInputType { Boolean, Number, Options, Quality, Action, } type PlanInputOption = { name: string; value: any; }; enum PlanInputActionArg { Number, String, Prefetch, } type PlanInput = { name?: string; type: PlanInputType; icon?: string; args?: PlanInputActionArg[]; options?: PlanInputOption[]; default?: any; }; const InputTemplates: Record<string, PlanInput> = { // PROPS paused: { type: PlanInputType.Boolean }, muted: { type: PlanInputType.Boolean }, loop: { type: PlanInputType.Boolean }, autoplay: { type: PlanInputType.Boolean }, // streamUrl?: string; this is set by url: liveLowLatency: { type: PlanInputType.Boolean }, rebufferToLive: { type: PlanInputType.Boolean }, playbackRate: { type: PlanInputType.Number }, logLevel: { type: PlanInputType.Options, options: [ { name: 'debug', value: LogLevel.IVSLogLevelDebug }, { name: 'info', value: LogLevel.IVSLogLevelInfo }, { name: 'warning', value: LogLevel.IVSLogLevelWarning }, { name: 'error', value: LogLevel.IVSLogLevelError }, ], }, resizeMode: { type: PlanInputType.Options, options: [ { name: 'fill', value: 'aspectFill' }, { name: 'fit', value: 'aspectFit' }, { name: 'zoom', value: 'aspectZoom' }, ], }, progressInterval: { type: PlanInputType.Number }, volume: { type: PlanInputType.Number }, quality: { type: PlanInputType.Quality }, autoMaxQuality: { type: PlanInputType.Quality }, autoQualityMode: { type: PlanInputType.Boolean }, // breakpoints: { type: PlanInputType.Boolean }, todo, do this later ?? maxBitrate: { type: PlanInputType.Number }, initialBufferDuration: { type: PlanInputType.Number }, pipEnabled: { type: PlanInputType.Boolean }, // REF API play: { type: PlanInputType.Action, icon: 'play' }, pause: { type: PlanInputType.Action, icon: 'pause' }, seekTo: { type: PlanInputType.Action, icon: 'fast-forward', args: [PlanInputActionArg.Number], }, setOrigin: { type: PlanInputType.Action, icon: 'crosshairs-gps', args: [PlanInputActionArg.String], }, togglePip: { type: PlanInputType.Action, icon: 'picture-in-picture-top-right-outline', }, loadSource: { type: PlanInputType.Action, icon: 'web', args: [PlanInputActionArg.Prefetch], }, }; const defaultUrl = `https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.DmumNckWFTqz.m3u8`; const defaultPlan = ` url: ${defaultUrl} inputs: `; const planState = proxy<{ url: string; props: PlanProps; events: Set<string>; inputs: PlanInput[]; actions: Record<string, PlanProps>; qualities: Quality[]; prefetchurls: string[]; prefetchsources: Record<string, Source | undefined>; }>({ url: '', props: {}, events: new Set(), inputs: [], actions: {}, qualities: [], prefetchurls: [], prefetchsources: {}, }); function qualitymatch(a: Quality | undefined, b: Quality | undefined) { // @ts-expect-error quick compare return a && b && Object.keys(a).every((key) => a[key] === b[key]); } type PlayerProps = { playerRef: React.Ref<IVSPlayerRef>; } & IVSPlayerProps; function logstring(name: string, message: string) { return `${name} ::: ${message}`.trim(); } function Player({ playerRef, ...props }: PlayerProps) { const snapshot = useSnapshot(planState); const [logs, setLogs] = React.useState<[string, string?][]>([]); function log(name: string, message?: any) { if (!snapshot.events.has(name)) { return; } if (typeof message === 'object') { const messages: [string, string?][] = []; const mainlog = logstring(name, ''); console.log(mainlog); messages.push([name, mainlog]); Object.entries(message).forEach(([key, value]) => { if (key) { const keyname = `${name} ::: ${key}`; const keylog = logstring(keyname, `${value}`); console.log(keylog); messages.push([keyname, keylog]); } }); setLogs((logs) => [...messages, ...logs.slice(0, 128)]); } else { const logmessage = logstring(name, `${message}`); console.log(logmessage); setLogs((logs) => [[name, logmessage], ...logs.slice(0, 128)]); } } return ( <> <IVSPlayer {...props} ref={playerRef} onSeek={(position) => { log('onSeek', `${position}`); }} onData={(data) => { planState.qualities = data.qualities; }} onVideoStatistics={(data) => { log('onVideoStatistics', data); }} onPlayerStateChange={(state) => { log('onPlayerStateChange', state); }} onDurationChange={(duration) => { log('onDurationChange', duration); }} onQualityChange={(quality) => { log('onQualityChange', quality); }} onPipChange={(isActive) => { log('onPipChange', isActive); }} onRebuffering={() => { log('onRebuffering'); }} onLoadStart={() => { log('onLoadStart'); }} onLoad={(duration) => { log('onLoad', duration); }} onLiveLatencyChange={(liveLatency) => { log('onLiveLatencyChange', liveLatency); }} onTextCue={(textCue) => { log('onTextCue', textCue); }} onTextMetadataCue={(textMetadataCue) => { log('onTextMetadataCue', textMetadataCue); }} onProgress={(progress) => { log('onProgress', progress); }} onError={(error: string) => { log('onError', error); }} onTimePoint={(position) => { log('onTimePoint', position); }} > {logs.length === 0 && ( <Text style={styles.log} testID="onClearLogs"> onClearLogs ::: </Text> )} {logs.map((log, index) => ( <Text key={`${index}${log[1]}`} style={styles.log} testID={log[0]} accessibilityLabel={log[1]} > {log[1]} </Text> ))} </IVSPlayer> <Button testID="clearLogs" onPress={() => setLogs([])}> Clear Logs </Button> </> ); } export function TestPlan() { const snapshot = useSnapshot(planState); const [testPlan, setTestPlan] = React.useState(defaultPlan); const playerRef = React.useRef<IVSPlayerRef>(null); React.useEffect(() => { return () => { planState.url = ''; planState.props = {}; planState.events = new Set(); planState.inputs = []; planState.actions = {}; planState.qualities = []; planState.prefetchurls = []; planState.prefetchsources = {}; }; }, []); function runplan() { const plandata = parse(testPlan); Object.keys(plandata).forEach((name) => { const lname = name.toLowerCase(); const value = plandata[name]; switch (lname) { case 'url': if (typeof value === 'string') { planState.url = value; } else { // throw error with example input } break; case 'inputs': if (Array.isArray(value)) { const newInputs: PlanInput[] = []; value.forEach((input) => { if (typeof input === 'string') { const template = InputTemplates[input]; if (template) { newInputs.push({ name: input, ...template }); } } else { Object.entries(input).forEach(([prop, data]) => { const template = InputTemplates[prop]; if (template) { planState.props[prop] = data; newInputs.push({ name: prop, ...template }); } }); } }); planState.inputs = newInputs; } else { // throw error with example input } break; case 'events': if (Array.isArray(value)) { const newEvents = new Set<string>(); value.forEach((event) => { if (typeof event === 'string') { newEvents.add(event); } else { // throw error with example input } }); planState.events = newEvents; } else { // throw error with example input } break; case 'prefetch': if (Array.isArray(value)) { const newPrefetchurls: string[] = []; value.forEach((input) => { if (typeof input === 'string') { newPrefetchurls.push(input); planState.prefetchsources[input] = playerRef.current?.preload(input); } else { // throw error with example input } }); planState.prefetchurls = newPrefetchurls; planState.inputs.push({ name: 'prefetch', type: PlanInputType.Action, icon: 'web', args: [PlanInputActionArg.Prefetch], }); } else { // throw error with example input } break; default: console.info(lname, plandata[name]); break; } }); if (!planState.url) { planState.url = defaultUrl; } } function renderinput(input: PlanInput) { const name = input.name ?? ''; const value = snapshot.props[name]; switch (input.type) { case PlanInputType.Boolean: return ( <> <Subheading>{name}</Subheading> <Chip testID={name} selected={!!value} onPress={() => { planState.props[name] = !value; }} > {JSON.stringify(value)} </Chip> </> ); case PlanInputType.Number: return ( <> <Subheading>{name}</Subheading> <TextInput testID={name} dense value={`${value}`} onChangeText={(text) => { const next = parseFloat(text); planState.props[name] = Number.isNaN(next) ? 0 : next; }} /> </> ); case PlanInputType.Options: return ( <> <Subheading>{name}</Subheading> {(input.options ?? []).map((option, index) => { return ( <Chip key={index} testID={`${name}:${option.name}`} selected={option.value === value} onPress={() => { planState.props[name] = option.value; }} > {logstring(option.name, JSON.stringify(option.value))} </Chip> ); })} </> ); case PlanInputType.Quality: return ( <> <Subheading>{name}</Subheading> <Chip testID={`${name}:auto:-1`} selected={value === undefined} onPress={() => { planState.props[name] = undefined; }} > {logstring('auto', 'undefined')} </Chip> {snapshot.qualities.map((option, index) => { return ( <Chip key={index} testID={`${name}:${option.name}`} selected={qualitymatch(option, value)} onPress={() => { planState.props[name] = option; }} > {logstring(option.name, JSON.stringify(option))} </Chip> ); })} </> ); case PlanInputType.Action: return ( <> <Subheading>{name}</Subheading> <View style={styles.row}> {(input.args ?? []).map((arg, i) => { switch (arg) { case PlanInputActionArg.Number: return ( <TextInput key={i} testID={`${name}:${i}`} dense style={styles.rowInput} value={`${planState.actions?.[name]?.[i] ?? ''}`} onChangeText={(text) => { if (!snapshot.actions[name]) { planState.actions[name] = {}; } const next = parseFloat(text); planState.actions[name][i] = Number.isNaN(next) ? 0 : next; }} /> ); case PlanInputActionArg.String: return ( <TextInput key={i} testID={`${name}:${i}`} dense style={styles.rowInput} value={planState.actions?.[name]?.[i] ?? ''} onChangeText={(text) => { if (!snapshot.actions[name]) { planState.actions[name] = {}; } planState.actions[name][i] = text; }} /> ); case PlanInputActionArg.Prefetch: return ( <View style={styles.col}> {snapshot.prefetchurls.map((url, index) => { return ( <View key={url} style={styles.row}> <IconButton testID={`${name}:${index}`} icon={ snapshot.prefetchsources[url] !== undefined ? 'web' : 'warning' } onPress={() => { const maybesource = snapshot.prefetchsources[url]; if ( maybesource !== undefined && playerRef.current ) { playerRef.current.loadSource(maybesource); } }} /> <Text>{url}</Text> </View> ); })} </View> ); default: return null; } })} {name !== 'prefetch' && ( <ToggleButton // @ts-expect-error docs say this prop exists? testID={name} icon={input.icon ?? ''} status="checked" onPress={() => { if (!playerRef.current) { return; } switch (name) { case 'play': playerRef.current.play(); break; case 'pause': playerRef.current.pause(); break; case 'seekTo': playerRef.current.seekTo(snapshot.actions[name][0]); break; case 'setOrigin': playerRef.current.setOrigin(snapshot.actions[name][0]); break; case 'togglePip': playerRef.current.togglePip(); break; } }} /> )} </View> </> ); } } return ( <ScrollView style={styles.container}> <View style={styles.player} testID="player"> <Player streamUrl={snapshot.prefetchurls.length ? '' : snapshot.url} playerRef={playerRef} {...snapshot.props} /> </View> <View style={styles.config}> {snapshot.inputs.map((input, index) => ( <View key={input.name ?? index} style={styles.input}> {renderinput(input as PlanInput)} </View> ))} <View style={styles.input}> <Button testID="runPlan" mode="contained" onPress={runplan}> Run Plan </Button> </View> <TextInput testID="testPlan" style={styles.testPlan} label="Test Plan" dense multiline value={testPlan} spellCheck={false} autoCorrect={false} autoCapitalize="none" onChangeText={setTestPlan} /> </View> </ScrollView> ); } const styles = StyleSheet.create({ container: { flex: 1, flexDirection: 'column', padding: 0, backgroundColor: '#acf', }, player: { marginVertical: 2, marginHorizontal: 8, height: 256, overflow: 'hidden', }, log: { color: '#fff', fontSize: 7, }, config: { flex: 1, }, input: { marginBottom: 10, marginHorizontal: 8, }, row: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 8, }, col: { flex: 1, flexDirection: 'column', }, rowInput: { flex: 1, marginRight: 10, }, testPlan: { flex: 1, minHeight: 400, }, });