|
5 | 5 | */ |
6 | 6 |
|
7 | 7 | import React from 'react' |
8 | | -import { |
9 | | - StyleSheet, |
10 | | - View, |
11 | | - Text, |
12 | | - ScrollView, |
13 | | - Image, |
14 | | - Dimensions, |
15 | | - Platform, |
16 | | -} from 'react-native' |
17 | | -import {Column} from '../components/layout' |
| 8 | +import {StyleSheet, View, Text, FlatList, Image, Platform} from 'react-native' |
| 9 | +import delay from 'delay' |
| 10 | +import {reportNetworkProblem} from '../../lib/report-network-problem' |
18 | 11 | import {TabBarIcon} from '../components/tabbar-icon' |
19 | 12 | import {Touchable} from '../components/touchable' |
20 | 13 | import * as c from '../components/colors' |
21 | 14 | import * as defaultData from '../../../docs/webcams.json' |
22 | 15 | import {webcamImages} from '../../../images/webcam-images' |
23 | 16 | import {trackedOpenUrl} from '../components/open-url' |
24 | 17 | import LinearGradient from 'react-native-linear-gradient' |
25 | | -import {partitionByIndex} from '../../lib/partition-by-index' |
| 18 | + |
| 19 | +const transparentPixel = require('../../../images/transparent.png') |
| 20 | +const GITHUB_URL = 'https://stodevx.github.io/AAO-React-Native/webcams.json' |
26 | 21 |
|
27 | 22 | type WebcamType = { |
28 | 23 | streamUrl: string, |
29 | 24 | pageUrl: string, |
30 | 25 | name: string, |
31 | 26 | thumbnail: string, |
| 27 | + thumbnailUrl?: string, |
| 28 | + textColor: string, |
32 | 29 | accentColor: [number, number, number], |
33 | 30 | } |
34 | 31 |
|
35 | | -type DProps = { |
36 | | - webcams: Array<WebcamType>, |
37 | | -} |
38 | | - |
39 | | -type Props = { |
40 | | - webcams: Array<WebcamType>, |
41 | | -} |
| 32 | +type Props = {} |
42 | 33 |
|
43 | 34 | type State = { |
44 | | - width: number, |
| 35 | + webcams: Array<WebcamType>, |
| 36 | + loading: boolean, |
| 37 | + refreshing: boolean, |
45 | 38 | } |
46 | 39 |
|
47 | | -export class WebcamsView extends React.PureComponent<DProps, Props, State> { |
| 40 | +export class WebcamsView extends React.PureComponent<void, Props, State> { |
48 | 41 | static navigationOptions = { |
49 | 42 | tabBarLabel: 'Webcams', |
50 | 43 | tabBarIcon: TabBarIcon('videocam'), |
51 | 44 | } |
52 | 45 |
|
53 | | - static defaultProps = { |
54 | | - webcams: defaultData.data, |
55 | | - } |
56 | | - |
57 | 46 | state = { |
58 | | - width: Dimensions.get('window').width, |
| 47 | + webcams: defaultData.data, |
| 48 | + loading: false, |
| 49 | + refreshing: false, |
59 | 50 | } |
60 | 51 |
|
61 | 52 | componentWillMount() { |
62 | | - Dimensions.addEventListener('change', this.handleResizeEvent) |
| 53 | + this.fetchData() |
63 | 54 | } |
64 | 55 |
|
65 | | - componentWillUnmount() { |
66 | | - Dimensions.removeEventListener('change', this.handleResizeEvent) |
| 56 | + refresh = async () => { |
| 57 | + const start = Date.now() |
| 58 | + this.setState(() => ({refreshing: true})) |
| 59 | + |
| 60 | + await this.fetchData() |
| 61 | + |
| 62 | + // wait 0.5 seconds – if we let it go at normal speed, it feels broken. |
| 63 | + const elapsed = Date.now() - start |
| 64 | + if (elapsed < 500) { |
| 65 | + await delay(500 - elapsed) |
| 66 | + } |
| 67 | + |
| 68 | + this.setState(() => ({refreshing: false})) |
67 | 69 | } |
68 | 70 |
|
69 | | - handleResizeEvent = (event: {window: {width: number}}) => { |
70 | | - this.setState(() => ({width: event.window.width})) |
| 71 | + fetchData = async () => { |
| 72 | + this.setState(() => ({loading: true})) |
| 73 | + |
| 74 | + let {data: webcams} = await fetchJson(GITHUB_URL).catch(err => { |
| 75 | + reportNetworkProblem(err) |
| 76 | + return defaultData |
| 77 | + }) |
| 78 | + |
| 79 | + if (process.env.NODE_ENV === 'development') { |
| 80 | + webcams = defaultData.data |
| 81 | + } |
| 82 | + |
| 83 | + this.setState(() => ({webcams, loading: false})) |
71 | 84 | } |
72 | 85 |
|
73 | | - render() { |
74 | | - const columns = partitionByIndex(this.props.webcams) |
| 86 | + renderItem = ({item}: {item: WebcamType}) => |
| 87 | + <StreamThumbnail key={item.name} webcam={item} /> |
| 88 | + |
| 89 | + keyExtractor = (item: WebcamType) => item.name |
75 | 90 |
|
| 91 | + render() { |
76 | 92 | return ( |
77 | | - <ScrollView contentContainerStyle={styles.gridWrapper}> |
78 | | - {columns.map((contents, i) => |
79 | | - <Column key={i} style={styles.column}> |
80 | | - {contents.map(webcam => |
81 | | - <StreamThumbnail |
82 | | - key={webcam.name} |
83 | | - webcam={webcam} |
84 | | - textColor="white" |
85 | | - viewportWidth={this.state.width} |
86 | | - />, |
87 | | - )} |
88 | | - </Column>, |
89 | | - )} |
90 | | - </ScrollView> |
| 93 | + <FlatList |
| 94 | + keyExtractor={this.keyExtractor} |
| 95 | + renderItem={this.renderItem} |
| 96 | + refreshing={this.state.refreshing} |
| 97 | + onRefresh={this.refresh} |
| 98 | + data={this.state.webcams} |
| 99 | + numColumns={2} |
| 100 | + columnWrapperStyle={styles.row} |
| 101 | + contentContainerStyle={styles.container} |
| 102 | + /> |
91 | 103 | ) |
92 | 104 | } |
93 | 105 | } |
94 | 106 |
|
95 | 107 | class StreamThumbnail extends React.PureComponent { |
96 | 108 | props: { |
97 | 109 | webcam: WebcamType, |
98 | | - textColor: 'white' | 'black', |
99 | | - viewportWidth: number, |
100 | 110 | } |
101 | 111 |
|
102 | 112 | handlePress = () => { |
103 | 113 | const {streamUrl, name, pageUrl} = this.props.webcam |
104 | | - if (Platform.OS === 'android') { |
| 114 | + if (Platform.OS === 'ios') { |
| 115 | + trackedOpenUrl({url: streamUrl, id: `${name}WebcamView`}) |
| 116 | + } else if (Platform.OS === 'android') { |
105 | 117 | trackedOpenUrl({url: pageUrl, id: `${name}WebcamView`}) |
106 | 118 | } else { |
107 | | - trackedOpenUrl({url: streamUrl, id: `${name}WebcamView`}) |
| 119 | + trackedOpenUrl({url: pageUrl, id: `${name}WebcamView`}) |
108 | 120 | } |
109 | 121 | } |
110 | 122 |
|
111 | 123 | render() { |
112 | | - const {textColor, viewportWidth} = this.props |
113 | | - const {name, thumbnail, accentColor} = this.props.webcam |
| 124 | + const { |
| 125 | + name, |
| 126 | + thumbnail, |
| 127 | + accentColor, |
| 128 | + textColor, |
| 129 | + thumbnailUrl, |
| 130 | + } = this.props.webcam |
114 | 131 |
|
115 | 132 | const [r, g, b] = accentColor |
116 | 133 | const baseColor = `rgba(${r}, ${g}, ${b}, 1)` |
117 | 134 | const startColor = `rgba(${r}, ${g}, ${b}, 0.1)` |
118 | | - const actualTextColor = c[textColor] |
119 | 135 |
|
120 | | - const width = viewportWidth / 2 - CELL_MARGIN * 1.5 |
121 | | - const cellRatio = 2.15625 |
122 | | - const height = width / cellRatio |
| 136 | + const img = thumbnailUrl |
| 137 | + ? {uri: thumbnailUrl} |
| 138 | + : webcamImages.hasOwnProperty(thumbnail) |
| 139 | + ? webcamImages[thumbnail] |
| 140 | + : transparentPixel |
123 | 141 |
|
124 | 142 | return ( |
125 | | - <View style={[styles.cell, styles.rounded, {width, height}]}> |
126 | | - <Touchable |
127 | | - highlight={true} |
128 | | - underlayColor={baseColor} |
129 | | - activeOpacity={0.7} |
130 | | - onPress={this.handlePress} |
131 | | - > |
132 | | - <Image source={webcamImages[thumbnail]} style={[styles.image]}> |
133 | | - <View style={styles.titleWrapper}> |
134 | | - <LinearGradient |
135 | | - colors={[startColor, baseColor]} |
136 | | - locations={[0, 0.8]} |
137 | | - > |
138 | | - <Text style={[styles.titleText, {color: actualTextColor}]}> |
139 | | - {name} |
140 | | - </Text> |
141 | | - </LinearGradient> |
142 | | - </View> |
143 | | - </Image> |
144 | | - </Touchable> |
145 | | - </View> |
| 143 | + <Touchable |
| 144 | + highlight={true} |
| 145 | + underlayColor={baseColor} |
| 146 | + activeOpacity={0.7} |
| 147 | + onPress={this.handlePress} |
| 148 | + containerStyle={styles.cell} |
| 149 | + > |
| 150 | + <Image source={img} style={styles.image}> |
| 151 | + <View style={styles.titleWrapper}> |
| 152 | + <LinearGradient |
| 153 | + colors={[startColor, baseColor]} |
| 154 | + locations={[0, 0.8]} |
| 155 | + > |
| 156 | + <Text style={[styles.titleText, {color: textColor}]}> |
| 157 | + {name} |
| 158 | + </Text> |
| 159 | + </LinearGradient> |
| 160 | + </View> |
| 161 | + </Image> |
| 162 | + </Touchable> |
146 | 163 | ) |
147 | 164 | } |
148 | 165 | } |
149 | 166 |
|
150 | 167 | const CELL_MARGIN = 10 |
151 | 168 |
|
152 | 169 | const styles = StyleSheet.create({ |
153 | | - column: { |
154 | | - flex: 1, |
| 170 | + container: { |
| 171 | + marginVertical: CELL_MARGIN / 2, |
155 | 172 | }, |
156 | | - gridWrapper: { |
157 | | - marginHorizontal: CELL_MARGIN / 2, |
158 | | - marginTop: CELL_MARGIN / 2, |
159 | | - paddingBottom: CELL_MARGIN / 2, |
160 | | - |
161 | | - alignItems: 'center', |
| 173 | + row: { |
162 | 174 | justifyContent: 'center', |
163 | | - flexDirection: 'row', |
164 | | - flexWrap: 'wrap', |
165 | | - }, |
166 | | - rounded: { |
167 | | - // TODO: Android doesn't currently (0.42) respect both |
168 | | - // overflow:hidden and border-radius. |
169 | | - borderRadius: Platform.OS === 'android' ? 0 : 6, |
| 175 | + marginHorizontal: CELL_MARGIN / 2, |
170 | 176 | }, |
171 | 177 | cell: { |
| 178 | + flex: 1, |
172 | 179 | overflow: 'hidden', |
173 | 180 | margin: CELL_MARGIN / 2, |
174 | 181 | justifyContent: 'center', |
175 | 182 |
|
176 | 183 | elevation: 2, |
| 184 | + |
| 185 | + // TODO: Android doesn't currently (0.42) respect both |
| 186 | + // overflow:hidden and border-radius. |
| 187 | + borderRadius: Platform.OS === 'android' ? 0 : 6, |
177 | 188 | }, |
178 | 189 | image: { |
179 | 190 | width: '100%', |
|
0 commit comments