diff --git a/flow-typed/npm/react-async_vx.x.x.js b/flow-typed/npm/react-async_vx.x.x.js new file mode 100644 index 0000000000..c1c703b339 --- /dev/null +++ b/flow-typed/npm/react-async_vx.x.x.js @@ -0,0 +1,79 @@ +// flow-typed signature: 768314abdaaf874222831e76fc320814 +// flow-typed version: <>/react-async_v5.1.2/flow_v0.92.1 + +/** + * This is an autogenerated libdef stub for: + * + * 'react-async' + * + * Fill this stub out by replacing all the `any` types. + * + * Once filled out, we encourage you to share your work with the + * community by sending a pull request to: + * https://github.com/flowtype/flow-typed + */ + +declare module 'react-async' { + import type {Node} from 'react' + import typeof * as React from 'react' + + declare export type AsyncChildren = + | ((state: AsyncState) => Node) + | Node + declare export type PromiseFn = ( + props: Object, + controller: AbortController, + ) => Promise + declare export type DeferFn = ( + args: any[], + props: Object, + controller: AbortController, + ) => Promise + + declare export interface AsyncOptions { + promise?: Promise; + promiseFn?: PromiseFn; + deferFn?: DeferFn; + watch?: any; + watchFn?: (props: Object, prevProps: Object) => any; + initialValue?: T; + onResolve?: (data: T) => void; + onReject?: (error: Error) => void; + // [prop: string]: any; + } + + declare export interface AsyncProps extends AsyncOptions { + children?: AsyncChildren; + } + + declare export interface AsyncState { + data?: T; + error?: Error; + initialValue?: T; + isLoading: boolean; + startedAt?: Date; + finishedAt?: Date; + counter: number; + cancel: () => void; + run: (...args: any[]) => Promise; + reload: () => void; + setData: (data: T, callback?: () => void) => T; + setError: (error: Error, callback?: () => void) => Error; + } + + declare export function useAsync( + arg1: AsyncOptions | PromiseFn, + arg2?: AsyncOptions, + ): AsyncState + + declare export interface FetchOptions extends AsyncOptions { + defer?: boolean; + json?: boolean; + } + + declare export function useFetch( + input: window.RequestInfo, + init?: window.RequestInit, + options?: FetchOptions, + ): AsyncState +} diff --git a/modules/colors/candy.js b/modules/colors/candy.js new file mode 100644 index 0000000000..29adb4abe7 --- /dev/null +++ b/modules/colors/candy.js @@ -0,0 +1,5 @@ +// @flow + +export const candyBlue = '#00ADD9' +export const candyGray = '#E0DAD6' +export const candyLime = '#A3D65C' diff --git a/modules/colors/index.js b/modules/colors/index.js index abf19e9361..e2bfaa5814 100644 --- a/modules/colors/index.js +++ b/modules/colors/index.js @@ -3,6 +3,7 @@ export {firstReadable} from './util' export * from './colors' +export * from './candy' export * from './gradients' export { diff --git a/modules/viewport/index.js b/modules/viewport/index.js index 2811ffb7df..7379c244a3 100644 --- a/modules/viewport/index.js +++ b/modules/viewport/index.js @@ -34,3 +34,19 @@ export class Viewport extends React.PureComponent { return this.props.render(this.state.viewport) } } + +export function useViewport() { + let [window, setWindow] = React.useState(Dimensions.get('window')) + + React.useEffect(() => { + function handleResizeEvent(event: {window: WindowDimensions}) { + setWindow(event.window) + } + + Dimensions.addEventListener('change', handleResizeEvent) + + return () => Dimensions.removeEventListener('change', handleResizeEvent) + }) + + return window +} diff --git a/package.json b/package.json index 9e1dd04086..5c1e0cf1a4 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "moment-timezone": "0.5.28", "p-props": "4.0.0", "p-retry": "4.2.0", + "polished": "3.1.0", "query-string": "6.12.1", "querystring": "0.2.0", "react": "16.9.0", diff --git a/source/lib/constants.js b/source/lib/constants.js index 09d6ce6da6..1afc1dfa2f 100644 --- a/source/lib/constants.js +++ b/source/lib/constants.js @@ -2,3 +2,4 @@ export const GH_BASE_URL = 'https://github.com/StoDevX/AAO-React-Native' export const GH_NEW_ISSUE_URL = `${GH_BASE_URL}/issues/new` +export const WEEKLY_MOVIE_URL = 'https://stodevx.github.io/sga-weekly-movies' diff --git a/source/views/streaming/index.js b/source/views/streaming/index.js index 5b721c359c..16d7f2fdd4 100644 --- a/source/views/streaming/index.js +++ b/source/views/streaming/index.js @@ -2,7 +2,7 @@ import {TabNavigator} from '../../../modules/navigation-tabs/tabbed-view' -// import WeeklyMovieView from './movie' +import {WeeklyMovieView} from './movie' import {WebcamsView} from './webcams' import {StreamListView} from './streams' import {KstoStationView} from './radio/station-ksto' @@ -15,7 +15,7 @@ const StreamingMediaView = TabNavigator({ LiveWebcamsView: {screen: WebcamsView}, KSTORadioView: {screen: KstoStationView}, KRLXRadioView: {screen: KrlxStationView}, - // WeeklyMovieView: {screen: WeeklyMovieView}, + WeeklyMovieView: {screen: WeeklyMovieView}, }) StreamingMediaView.navigationOptions = { diff --git a/source/views/streaming/movie.js b/source/views/streaming/movie.js deleted file mode 100644 index d404960f32..0000000000 --- a/source/views/streaming/movie.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow - -import * as React from 'react' -import {StyleSheet, View, Text} from 'react-native' -import {TabBarIcon} from '@frogpond/navigation-tabs' - -export default function WeeklyMovieView() { - return ( - - Movie - - ) -} -WeeklyMovieView.navigationOptions = { - tabBarLabel: 'Weekly Movie', - tabBarIcon: TabBarIcon('film'), -} - -let styles = StyleSheet.create({ - container: { - flex: 1, - }, -}) diff --git a/source/views/streaming/movie/components/credits/credits-android.js b/source/views/streaming/movie/components/credits/credits-android.js new file mode 100644 index 0000000000..09b7f508fb --- /dev/null +++ b/source/views/streaming/movie/components/credits/credits-android.js @@ -0,0 +1,64 @@ +// @flow + +import * as React from 'react' +import {StyleSheet} from 'react-native' +import {Card, Subheading, Paragraph} from 'react-native-paper' + +type Props = { + directors: string, + writers: string, + actors: string, +} + +export const AndroidCredits = ({directors, writers, actors}: Props) => { + return ( + <> + {writers === directors ? ( + + + + Written and Directed By + {directors} + + + + Cast + {actors} + + + ) : ( + <> + + + + Directed By + {directors} + + + + Written By + {writers} + + + + Cast + {actors} + + + + )} + + ) +} + +const styles = StyleSheet.create({ + card: { + elevation: 2, + marginBottom: 10, + marginHorizontal: 10, + }, + + content: { + marginBottom: 12, + }, +}) diff --git a/source/views/streaming/movie/components/credits/credits-ios.js b/source/views/streaming/movie/components/credits/credits-ios.js new file mode 100644 index 0000000000..a60909a2be --- /dev/null +++ b/source/views/streaming/movie/components/credits/credits-ios.js @@ -0,0 +1,43 @@ +// @flow + +import * as React from 'react' +import {Heading, Text, Padding, SectionHeading} from '../parts' +import {Column} from '@frogpond/layout' + +type Props = { + directors: string, + writers: string, + actors: string, +} + +export const IosCredits = ({directors, writers, actors}: Props) => { + return ( + <> + CREDITS + + {writers === directors ? ( + + Written and Directed By + {directors} + + ) : ( + + + Directed By + {directors} + + + Written By + {writers} + + + )} + + + Cast + {actors} + + + + ) +} diff --git a/source/views/streaming/movie/components/credits/index.js b/source/views/streaming/movie/components/credits/index.js new file mode 100644 index 0000000000..30a630009b --- /dev/null +++ b/source/views/streaming/movie/components/credits/index.js @@ -0,0 +1,7 @@ +// @flow + +import {Platform} from 'react-native' +import {IosCredits} from './credits-ios' +import {AndroidCredits} from './credits-android' + +export const Credits = Platform.OS === 'ios' ? IosCredits : AndroidCredits diff --git a/source/views/streaming/movie/components/genres.js b/source/views/streaming/movie/components/genres.js new file mode 100644 index 0000000000..529b8cba14 --- /dev/null +++ b/source/views/streaming/movie/components/genres.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react' +import {Pill} from './pill' +import * as c from '@frogpond/colors' +import glamorous from 'glamorous-native' + +export function Genres({genres}: {genres: Array}) { + return ( + + {genres.map((genre) => ( + + {genre.toLowerCase()} + + ))} + + ) +} diff --git a/source/views/streaming/movie/components/parts.js b/source/views/streaming/movie/components/parts.js new file mode 100644 index 0000000000..8fa4c36c77 --- /dev/null +++ b/source/views/streaming/movie/components/parts.js @@ -0,0 +1,111 @@ +// @flow + +import {Platform, TouchableWithoutFeedback} from 'react-native' +import * as React from 'react' +import * as c from '@frogpond/colors' +import glamorous from 'glamorous-native' +import {human, material} from 'react-native-typography' + +export const Padding = glamorous.view({ + paddingHorizontal: 16, +}) + +export const MovieInfo = glamorous(Padding)({ + marginTop: 18, +}) + +export const Spacer = glamorous.view({flex: 1}) + +export const FixedSpacer = glamorous.view({flexBasis: 16}) + +export const Title = glamorous.text({ + ...Platform.select({ + ios: human.title1Object, + android: material.titleObject, + }), +}) + +export const SectionHeading = glamorous.text({ + ...Platform.select({ + ios: human.subheadObject, + android: material.subheadingObject, + }), + fontWeight: '900', + paddingHorizontal: 16, + marginTop: 24, +}) + +export const Card = glamorous.view({ + backgroundColor: c.white, + borderRadius: 8, + shadowRadius: 12, + shadowOpacity: 0.2, + shadowOffset: {height: 4, width: 4}, +}) + +export const PaddedCard = ({children}: {children: React.Node}) => ( + + {children} + +) + +export const Heading = glamorous.text({ + ...human.subheadObject, + fontWeight: '700', +}) + +export const Text = glamorous.text({...human.bodyObject}) + +type ShrinkWhenTouchedProps = { + onPress: () => any, + style?: any, + children: React.Node, +} +type ShrinkWhenTouchedState = { + pressed: boolean, +} +export class ShrinkWhenTouched extends React.PureComponent< + ShrinkWhenTouchedProps, + ShrinkWhenTouchedState, +> { + state = {pressed: false} + + onPressIn = () => this.setState(() => ({pressed: true})) + onPressOut = () => this.setState(() => ({pressed: false})) + + render() { + const {children, style, onPress} = this.props + const {pressed: isPressed} = this.state + + return ( + + + {children} + + + ) + } +} + +export const FooterAction = (props: {onPress: () => {}, text: string}) => { + const {onPress, text} = props + + return ( + + {text} + + ) +} diff --git a/source/views/streaming/movie/components/pill.js b/source/views/streaming/movie/components/pill.js new file mode 100644 index 0000000000..dce99e39ec --- /dev/null +++ b/source/views/streaming/movie/components/pill.js @@ -0,0 +1,28 @@ +// @flow + +import * as React from 'react' +import * as c from '@frogpond/colors' +import {firstReadable} from '@frogpond/colors' +import glamorous from 'glamorous-native' + +type Props = { + children: React.Node, + bgColor: string, +} + +export const Pill = ({children, bgColor, ...props}: Props) => { + return ( + + + {children} + + + ) +} diff --git a/source/views/streaming/movie/components/plot/index.js b/source/views/streaming/movie/components/plot/index.js new file mode 100644 index 0000000000..f42e94d06d --- /dev/null +++ b/source/views/streaming/movie/components/plot/index.js @@ -0,0 +1,7 @@ +// @flow + +import {Platform} from 'react-native' +import {IosPlot} from './plot-ios' +import {AndroidPlot} from './plot-android' + +export const Plot = Platform.OS === 'ios' ? IosPlot : AndroidPlot diff --git a/source/views/streaming/movie/components/plot/plot-android.js b/source/views/streaming/movie/components/plot/plot-android.js new file mode 100644 index 0000000000..584ffbbcf6 --- /dev/null +++ b/source/views/streaming/movie/components/plot/plot-android.js @@ -0,0 +1,28 @@ +// @flow + +import * as React from 'react' +import {Card, Paragraph} from 'react-native-paper' +import {StyleSheet} from 'react-native' + +export const AndroidPlot = ({text}: {text: string}) => { + return ( + + + + {text} + + + ) +} + +const styles = StyleSheet.create({ + card: { + elevation: 2, + marginBottom: 10, + marginHorizontal: 10, + }, + + content: { + marginBottom: 12, + }, +}) diff --git a/source/views/streaming/movie/components/plot/plot-ios.js b/source/views/streaming/movie/components/plot/plot-ios.js new file mode 100644 index 0000000000..007193bcff --- /dev/null +++ b/source/views/streaming/movie/components/plot/plot-ios.js @@ -0,0 +1,15 @@ +// @flow + +import * as React from 'react' +import {Padding, Text, SectionHeading} from '../parts' + +export const IosPlot = ({text, ...props}: {text: string}) => { + return ( + <> + PLOT + + {text} + + + ) +} diff --git a/source/views/streaming/movie/components/poster.js b/source/views/streaming/movie/components/poster.js new file mode 100644 index 0000000000..6f0587d32c --- /dev/null +++ b/source/views/streaming/movie/components/poster.js @@ -0,0 +1,65 @@ +// @flow + +import * as React from 'react' +import * as c from '@frogpond/colors' +import glamorous from 'glamorous-native' +import {setSaturation, setLightness} from 'polished' +import type {PosterInfo} from '../types' +import {ShrinkWhenTouched} from './parts' +import {useViewport} from '@frogpond/viewport' + +type PosterProps = { + sizes: Array, + ideal: number, + tint: string, + onPress: () => any, +} + +const PosterImage = (props: PosterProps) => { + const {sizes, ideal} = props + const viewport = useViewport() + + const landscape = viewport.width > viewport.height + const width = landscape + ? Math.min(viewport.height / 2, 200) + : Math.min(viewport.width / 3, 300) + const ratio = 1.481 // from ebay.com/gds/Movie-Poster-Size-Guide-/10000000005754120/g.html + + // TODO: find the largest size beneath `ideal` + const poster = sizes.find((p) => p.width === ideal) + + // TODO: provide a fallback image + const uri = poster ? poster.url : '' + + return ( + + ) +} + +export const Poster = (props: PosterProps & {left: number}) => { + // TODO: find way to avoid backgroundColor:transparent on wrapper + + return ( + + + + + + ) +} diff --git a/source/views/streaming/movie/components/ratings.js b/source/views/streaming/movie/components/ratings.js new file mode 100644 index 0000000000..1c3215a77d --- /dev/null +++ b/source/views/streaming/movie/components/ratings.js @@ -0,0 +1,128 @@ +// @flow + +import * as React from 'react' +import {Platform, View} from 'react-native' +import glamorous from 'glamorous-native' +import Icon from 'react-native-vector-icons/Ionicons' +import type {MovieRating} from '../types' + +type RatingsProps = { + ratings: Array, +} + +const FullStar = () => ( + +) +const HalfStar = () => ( + +) +const EmptyStar = () => ( + +) + +export const ImdbRating = ({ratings}: RatingsProps) => { + const rating = ratings.find((r) => r.Source === 'Internet Movie Database') + + if (!rating) { + return Unrated + } + + const score = normalizeScore(rating.Value) + if (!score) { + return Unrated + } + + const tint = colorizeScore(rating.Value) + + return ( + + + + {score / 10} + + {' ⁄ '} + 10 + + + + IMDB + + + ) +} + +export const RottenTomatoesRating = ({ratings}: RatingsProps) => { + const rating = ratings.find((r) => r.Source === 'Rotten Tomatoes') + + if (!rating) { + return Unrated + } + + const score = normalizeScore(rating.Value) + if (!score) { + return Unrated + } + + const stars = Math.round(score / 20) + const starIcons = [] + for (let i = 0; i < 5; i++) { + if (i < stars) { + starIcons.push() + } else if (i === stars && i % 2 === 1) { + starIcons.push() + } else { + starIcons.push() + } + } + + const tint = colorizeScore(rating.Value) + return ( + + {starIcons} + + Rotten Tomatoes + + + ) +} + +export const MpaaRating = ({rated}: {rated: string}) => ( + + + {rated} + + +) + +function normalizeScore(score: string): ?number { + if (score.endsWith('%')) { + // XX% + score = score.replace('%', '') + return parseInt(score) + } else if (score.includes('/')) { + // X/10 + score = score.split('/')[0] + return Math.round(parseFloat(score) * 10) + } + + return null +} + +function colorizeScore(score: string) { + let numScore = normalizeScore(score) + + if (!numScore) { + return 'black' + } + + const MAX_VALUE = 200 + const normalizedScore = Math.round((numScore / 100) * MAX_VALUE) + + return `rgb(${MAX_VALUE - normalizedScore}, ${normalizedScore}, 0)` +} diff --git a/source/views/streaming/movie/components/showings.js b/source/views/streaming/movie/components/showings.js new file mode 100644 index 0000000000..3e67db58a0 --- /dev/null +++ b/source/views/streaming/movie/components/showings.js @@ -0,0 +1,72 @@ +// @flow + +import * as React from 'react' +import * as c from '@frogpond/colors' +import glamorous from 'glamorous-native' +import {Row, Column} from '@frogpond/layout' +import type {MovieShowing, GroupedShowing} from '../types' +import {Card} from './parts' +import {groupShowings} from '../lib/group-showings' + +export const Showings = ({showings}: {showings: ?Array}) => { + if (!showings || !showings.length) { + return No Showings + } + + return ( + + {groupShowings(showings).map((s) => ( + + ))} + + ) +} + +const PaddedShowingsCard = ({children}) => ( + + {children} + +) + +const BigText = glamorous.text({ + color: c.black, + fontSize: 22, + lineHeight: 22, + fontWeight: '900', +}) + +const DimText = glamorous.text({ + color: c.iosDisabledText, + fontSize: 14, + lineHeight: 22, +}) + +const SmallText = glamorous.text({ + color: c.black, + fontSize: 13, + fontVariant: ['small-caps'], +}) + +const ShowingTile = ({item}: {item: GroupedShowing}) => { + return ( + + + + {item.date} + {item.month} + + + + {item.location} + {item.times.join(' • ')} + + + + ) +} diff --git a/source/views/streaming/movie/components/trailer-background.js b/source/views/streaming/movie/components/trailer-background.js new file mode 100644 index 0000000000..8f665eb658 --- /dev/null +++ b/source/views/streaming/movie/components/trailer-background.js @@ -0,0 +1,87 @@ +// @flow + +import * as React from 'react' +import {StyleSheet} from 'react-native' +import * as c from '@frogpond/colors' +import glamorous from 'glamorous-native' +import {darken, transparentize, rgb} from 'polished' +import type {Movie, MovieTrailerThumbnail, RGBTuple} from '../types' +import LinearGradient from 'react-native-linear-gradient' +import {useViewport} from '@frogpond/viewport' + +const makeRgb = (tuple: RGBTuple) => rgb(...tuple) + +type Props = { + movie: Movie, + background: ?MovieTrailerThumbnail, + tint: string, + height: number, +} + +export const TrailerBackground = (props: Props) => { + const {movie, background, tint: posterTint, height} = props + const viewport = useViewport() + + // TODO: provide a fallback image + const uri = background ? background.url : '' + + const tint = makeRgb(movie.poster.colors.dominant) || posterTint + + const gradient = [ + c.transparent, + c.transparent, + // c.transparent, + // c.black, + // setLightness(0.35, setSaturation(0.25, tint)), + darken(0.1, transparentize(0.5, tint)), + ] + + return ( + + + + + + + + ) +} + +type TriangleOverlayProps = { + height: number, + width: number, +} + +const TriangleOverlay = ({height, width}: TriangleOverlayProps) => { + return ( + + ) +} diff --git a/source/views/streaming/movie/components/trailers/index.js b/source/views/streaming/movie/components/trailers/index.js new file mode 100644 index 0000000000..895cfe7d6f --- /dev/null +++ b/source/views/streaming/movie/components/trailers/index.js @@ -0,0 +1,7 @@ +// @flow + +import {Platform} from 'react-native' +import {IosTrailers} from './trailers-ios' +import {AndroidTrailers} from './trailers-android' + +export const Trailers = Platform.OS === 'ios' ? IosTrailers : AndroidTrailers diff --git a/source/views/streaming/movie/components/trailers/trailers-android.js b/source/views/streaming/movie/components/trailers/trailers-android.js new file mode 100644 index 0000000000..3f700b04de --- /dev/null +++ b/source/views/streaming/movie/components/trailers/trailers-android.js @@ -0,0 +1,133 @@ +// @flow + +import * as React from 'react' +import {StyleSheet} from 'react-native' +import * as c from '@frogpond/colors' +import {openUrl} from '@frogpond/open-url' +import glamorous from 'glamorous-native' +import maxBy from 'lodash/maxBy' +import type {MovieTrailer} from '../../types' +import {ShrinkWhenTouched} from '../parts' +import LinearGradient from 'react-native-linear-gradient' +import Icon from 'react-native-vector-icons/Ionicons' +import {Card} from 'react-native-paper' + +type Viewport = {width: number, height: number} + +export const AndroidTrailers = (props: { + trailers: Array, + viewport: Viewport, +}) => { + const {viewport, trailers: allTrailers} = props + + const trailers = allTrailers.filter((t) => t.type === 'Trailer') + const teasers = allTrailers.filter((t) => t.type === 'Teaser') + const featurettes = allTrailers.filter((t) => t.type === 'Featurette') + const clips = allTrailers.filter((t) => t.type === 'Clip') + + return ( + <> + + + + + + ) +} + +const Clips = (props: { + viewport: Viewport, + title: string, + clips: Array, +}) => { + const {clips, title, viewport} = props + + if (!clips.length) { + return null + } + + return ( + <> + + + {clips.map((t) => ( + openUrl(t.url)}> + + + ))} + + + ) +} + +const AndroidSpacedCard = ({children}) => { + return {children} +} + +const Padding = glamorous.view({ + paddingBottom: 8, + paddingHorizontal: 10, + paddingTop: 10, +}) + +const ClipTitle = glamorous.text({ + color: c.white, + fontSize: 18, + fontWeight: '700', +}) + +const ClipTile = (props: {clip: MovieTrailer, viewport: Viewport}) => { + const {clip, viewport} = props + + // TODO: pick appropriate thumbnail + const thumbnailUrl = maxBy(clip.thumbnails, (t) => t.width).url + const width = Math.min(300, viewport.width - 75) + + return ( + + + + + + + + + {clip.name} + {' '} + + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + elevation: 2, + marginBottom: 10, + marginHorizontal: 10, + }, + content: { + justifyContent: 'flex-end', + }, + container: { + paddingHorizontal: 6, + }, + cardBorderRadius: { + borderRadius: 8, + }, +}) diff --git a/source/views/streaming/movie/components/trailers/trailers-ios.js b/source/views/streaming/movie/components/trailers/trailers-ios.js new file mode 100644 index 0000000000..aeb0c6ca5b --- /dev/null +++ b/source/views/streaming/movie/components/trailers/trailers-ios.js @@ -0,0 +1,130 @@ +// @flow + +import * as React from 'react' +import {StyleSheet} from 'react-native' +import * as c from '@frogpond/colors' +import {openUrl} from '@frogpond/open-url' +import glamorous from 'glamorous-native' +import maxBy from 'lodash/maxBy' +import type {MovieTrailer} from '../../types' +import {SectionHeading, Card, ShrinkWhenTouched} from '../parts' +import LinearGradient from 'react-native-linear-gradient' +import Icon from 'react-native-vector-icons/Ionicons' + +type Viewport = {width: number, height: number} + +export const IosTrailers = (props: { + trailers: Array, + viewport: Viewport, +}) => { + const {viewport, trailers: allTrailers} = props + + const trailers = allTrailers.filter((t) => t.type === 'Trailer') + const teasers = allTrailers.filter((t) => t.type === 'Teaser') + const featurettes = allTrailers.filter((t) => t.type === 'Featurette') + const clips = allTrailers.filter((t) => t.type === 'Clip') + + return ( + <> + + + + + + ) +} + +const Clips = (props: { + viewport: Viewport, + title: string, + clips: Array, +}) => { + const {clips, title, viewport} = props + + if (!clips.length) { + return null + } + + return ( + <> + {title} + + {clips.map((t) => ( + + ))} + + + ) +} + +const SpacedCard = ({viewport, children}) => { + const width = Math.min(300, viewport.width - 75) + return ( + + {children} + + ) +} + +const Padding = glamorous.view({ + paddingBottom: 8, + paddingHorizontal: 10, + paddingTop: 10, +}) + +const ClipTitle = glamorous.text({ + color: c.white, + fontSize: 18, + fontWeight: '700', +}) + +const ClipTile = (props: {clip: MovieTrailer, viewport: Viewport}) => { + const {clip, viewport} = props + + // TODO: pick appropriate thumbnail + const thumbnailUrl = maxBy(clip.thumbnails, (t) => t.width).url + + return ( + openUrl(clip.url)}> + + + + + + + + {clip.name} + {' '} + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 6, + }, + cardBorderRadius: { + borderRadius: 8, + }, +}) diff --git a/source/views/streaming/movie/index.js b/source/views/streaming/movie/index.js new file mode 100644 index 0000000000..5e4c1e8948 --- /dev/null +++ b/source/views/streaming/movie/index.js @@ -0,0 +1,3 @@ +// @flow + +export {WeeklyMovieView} from './view' diff --git a/source/views/streaming/movie/lib/__tests__/group-showing.test.js b/source/views/streaming/movie/lib/__tests__/group-showing.test.js new file mode 100644 index 0000000000..f917613d62 --- /dev/null +++ b/source/views/streaming/movie/lib/__tests__/group-showing.test.js @@ -0,0 +1,118 @@ +/* eslint-env jest */ +// @flow + +import {groupShowings} from '../group-showings' +import type {MovieShowing} from '../../types' + +test('groups showings on the same day together', () => { + const input: Array = [ + {time: '2017-01-01T00:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T06:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T12:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T18:00:00-06:00', location: 'Viking'}, + ] + + const output = groupShowings(input) + + const expected = [ + { + key: '1-jan-Viking', + date: '1', + month: 'jan', + location: 'Viking', + times: ['12am', '6am', '12pm', '6pm'], + }, + ] + + expect(output).toEqual(expected) +}) + +test('groups showings on different days together', () => { + const input: Array = [ + {time: '2017-01-01T00:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T06:00:00-06:00', location: 'Viking'}, + {time: '2017-01-02T12:00:00-06:00', location: 'Viking'}, + {time: '2017-01-02T18:00:00-06:00', location: 'Viking'}, + ] + + const output = groupShowings(input) + + const expected = [ + { + key: '1-jan-Viking', + date: '1', + month: 'jan', + location: 'Viking', + times: ['12am', '6am'], + }, + { + key: '2-jan-Viking', + date: '2', + month: 'jan', + location: 'Viking', + times: ['12pm', '6pm'], + }, + ] + + expect(output).toEqual(expected) +}) + +test('groups showings by day, then by location', () => { + const input: Array = [ + {time: '2017-01-01T00:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T06:00:00-06:00', location: 'Tomson'}, + {time: '2017-01-01T12:00:00-06:00', location: 'Viking'}, + {time: '2017-01-01T18:00:00-06:00', location: 'Tomson'}, + ] + + const output = groupShowings(input) + + const expected = [ + { + key: '1-jan-Viking', + date: '1', + month: 'jan', + location: 'Viking', + times: ['12am', '12pm'], + }, + { + key: '1-jan-Tomson', + date: '1', + month: 'jan', + location: 'Tomson', + times: ['6am', '6pm'], + }, + ] + + expect(output).toEqual(expected) +}) + +test('sorts showings lexiographically', () => { + const input: Array = [ + {time: '2017-01-09T00:00:00-06:00', location: 'Viking'}, + {time: '2017-01-09T06:00:00-06:00', location: 'Viking'}, + {time: '2017-01-10T12:00:00-06:00', location: 'Viking'}, + {time: '2017-01-10T18:00:00-06:00', location: 'Viking'}, + ] + + const output = groupShowings(input) + + const expected = [ + { + key: '9-jan-Viking', + date: '9', + month: 'jan', + location: 'Viking', + times: ['12am', '6am'], + }, + { + key: '10-jan-Viking', + date: '10', + month: 'jan', + location: 'Viking', + times: ['12pm', '6pm'], + }, + ] + + expect(output).toEqual(expected) +}) diff --git a/source/views/streaming/movie/lib/group-showings.js b/source/views/streaming/movie/lib/group-showings.js new file mode 100644 index 0000000000..b42af9f453 --- /dev/null +++ b/source/views/streaming/movie/lib/group-showings.js @@ -0,0 +1,48 @@ +// @flow + +import moment from 'moment-timezone' +import values from 'lodash/values' +import sortBy from 'lodash/sortBy' +import type {MovieShowing, GroupedShowing} from '../types' +const TIMEZONE = 'America/Winnipeg' + +export function groupShowings( + showings: Array, +): Array { + const grouped = showings.reduce((grouped, showing) => { + const m = moment.tz(showing.time, TIMEZONE) + + const date = m.format('D') + const month = m.format('MMM').toLowerCase() + const location = showing.location + + const key = `${date}-${month}-${location}` + + const time = + m.minutes() === 0 + ? m.format('hA').toLowerCase() + : m.format('h:mmA').toLowerCase() + + let grouping = { + key, + date, + month, + location, + times: [], + } + + if (!(key in grouped)) { + grouped[key] = grouping + } + + grouped[key].times.push(time) + + return grouped + }, {}) + + return sortBy( + values(grouped), + ({date, month, times}) => + `${String(date).padStart(2, '0')}-${month}-${times[0]}`, + ) +} diff --git a/source/views/streaming/movie/types.js b/source/views/streaming/movie/types.js new file mode 100644 index 0000000000..ce3d2d2876 --- /dev/null +++ b/source/views/streaming/movie/types.js @@ -0,0 +1,81 @@ +// @flow + +export type RGBTuple = [number, number, number] + +export type PosterInfo = { + url: string, + filename: string, + width: number, + height: number, +} + +export type MovieTrailerThumbnail = { + url: string, + filename: string, + width: number, + height: number, +} + +export type MovieTrailer = { + name: string, + type: 'Trailer' | 'Teaser' | 'Featurette' | 'Clip', + url: string, + lang: string, + thumbnails: Array, + colors: { + dominant: RGBTuple, + palette: Array, + }, +} + +export type MovieShowing = {time: string, location: string} + +export type GroupedShowing = { + // generated by groupShowings + key: string, + date: string, + month: string, + location: string, + times: Array, +} + +export type MovieRating = {Source: string, Value: string} + +export type MovieInfo = { + Title: string, + Year: string, + Rated: string, + Released: string, + ReleaseDate: string, + Runtime: string, + Genre: string, + Genres: Array, + Director: string, + Writer: string, + Actors: string, + Plot: string, + Language: string, + Country: string, + Awards: string, + Ratings: Array, + Type: string, + DVD: string, + BoxOffice: string, + Production: string, + imdbID: string, + Website: string, +} + +export type Movie = { + root: string, + info: MovieInfo, + showings: Array, + trailers: Array, + poster: { + sizes: Array, + colors: { + dominant: RGBTuple, + palette: Array, + }, + }, +} diff --git a/source/views/streaming/movie/view.js b/source/views/streaming/movie/view.js new file mode 100644 index 0000000000..27e6dbda50 --- /dev/null +++ b/source/views/streaming/movie/view.js @@ -0,0 +1,183 @@ +// @flow + +import * as React from 'react' +import {StyleSheet, Platform, ScrollView} from 'react-native' +import moment from 'moment-timezone' +import glamorous from 'glamorous-native' +import {rgb} from 'polished' + +import {TabBarIcon} from '@frogpond/navigation-tabs' +import {LoadingView, NoticeView} from '@frogpond/notice' +import * as c from '@frogpond/colors' +import {Row} from '@frogpond/layout' +import {openUrl} from '@frogpond/open-url' +import {ListSeparator} from '@frogpond/lists' +import {useViewport} from '@frogpond/viewport' +import {fetch} from '@frogpond/fetch' +import {WEEKLY_MOVIE_URL} from '../../../lib/constants' + +import { + MovieInfo, + Title, + Spacer, + FixedSpacer, + FooterAction, +} from './components/parts' +import {Pill} from './components/pill' +import {Poster} from './components/poster' +import {TrailerBackground} from './components/trailer-background' +import {Genres} from './components/genres' +import { + RottenTomatoesRating, + ImdbRating, + MpaaRating, +} from './components/ratings.js' +import {Showings} from './components/showings' +import {Plot} from './components/plot' +import {Credits} from './components/credits' +import {Trailers} from './components/trailers' +import type {Movie, RGBTuple, MovieTrailerThumbnail} from './types' +import {useAsync} from 'react-async' + +async function fetchWeeklyMovie(_, {signal}): Promise { + let url = `${WEEKLY_MOVIE_URL}/next.json` + const nextMovie = await fetch(url, {signal}).json() + return fetch(nextMovie.movie, {signal}).json() +} + +const makeRgb = (tuple: RGBTuple) => rgb(...tuple) + +function findLargestTrailerImage(movie: Movie): ?MovieTrailerThumbnail { + if (!movie.trailers && movie.trailers.size) { + return null + } + + return movie.trailers + .map((trailer) => trailer.thumbnails.find((thm) => thm.width === 640)) + .find((trailer) => trailer) +} + +export function WeeklyMovieView() { + const viewport = useViewport() + + let {data: movie, error, isLoading, reload} = useAsync({ + promiseFn: fetchWeeklyMovie, + }) + + if (isLoading) { + return + } + + if (error) { + return ( + + ) + } + + if (!movie) { + return + } + + // TODO: handle odd-shaped posters + // TODO: style for Android + // TODO: handle "no movie posted yet this week" + // TODO: handle "no movie will show this week" + // TODO: handle all movie showing dates are past + // TODO: also handle after-last-showing on last showing date + // TODO: remove the Play button + // TODO: handle multiple movies on one weekend + + const largestTrailerImage = findLargestTrailerImage(movie) + const movieTint = makeRgb(movie.poster.colors.dominant) + const landscape = viewport.width > viewport.height + const headerHeight = landscape + ? Math.max(viewport.height * (2 / 3), 200) + : Math.max(viewport.height / 3, 200) + const imdbUrl = `https://www.imdb.com/title/${movie.info.imdbID}` + + return ( + + + + + openUrl(imdbUrl)} + sizes={movie.poster.sizes} + tint={movieTint} + /> + + + + {movie.info.Title} + + + + {moment(movie.info.ReleaseDate).format('YYYY')} + + {movie.info.Runtime} + + + + + + + + + + + + + + + + + + + + + + + + + + openUrl(imdbUrl)} text="Open IMDB Page" /> + + + + ) +} + +WeeklyMovieView.navigationOptions = { + tabBarLabel: 'Movie', + tabBarIcon: TabBarIcon('film'), +} + +const styles = StyleSheet.create({ + contentContainer: { + ...Platform.select({ + ios: { + backgroundColor: c.white, + }, + }), + }, +}) diff --git a/yarn.lock b/yarn.lock index 34203578aa..d402eb5086 100644 --- a/yarn.lock +++ b/yarn.lock @@ -221,11 +221,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== -"@babel/helper-validator-identifier@^7.9.5": - version "7.9.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" - integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== - "@babel/helper-wrap-function@^7.1.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa" @@ -688,6 +683,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.4.2": + version "7.4.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.2.tgz#f5ab6897320f16decd855eed70b705908a313fe8" + integrity sha512-7Bl2rALb7HpvXFL7TETNzKSAeBVCPHELzc0C//9FCxN8nsiueWSJBqaF+2oIJScyILStASR/Cx5WMkXGYTiJFA== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -712,16 +714,7 @@ globals "^11.1.0" lodash "^4.17.13" -"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.5.5", "@babel/types@^7.7.0", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6": - version "7.9.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.6.tgz#2c5502b427251e9de1bd2dff95add646d95cc9f7" - integrity sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA== - dependencies: - "@babel/helper-validator-identifier" "^7.9.5" - lodash "^4.17.13" - to-fast-properties "^2.0.0" - -"@babel/types@^7.3.3": +"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.5.5", "@babel/types@^7.7.0", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.6.tgz#2c5502b427251e9de1bd2dff95add646d95cc9f7" integrity sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA== @@ -7026,6 +7019,13 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== +polished@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/polished/-/polished-3.1.0.tgz#8f77221e1a72885931da6d56e2bc3131a027c47a" + integrity sha512-b/5/QSn49Diy+kxPCIczxOz0YEX/j/XOc/QNFyKz3TcMymQpCILgYyqMDqPa4HoDCVq1bJgHamhR2gFGrIfLug== + dependencies: + "@babel/runtime" "^7.4.2" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"