-
-
Save kahunamoore/c4802d054f3691cae2ccf0bbe8c794a0 to your computer and use it in GitHub Desktop.
Player code for my Apple Music clone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // @flow | |
| import * as React from "react"; | |
| import { | |
| View, | |
| StyleSheet, | |
| Image, | |
| Animated, | |
| TouchableOpacity, | |
| Text, | |
| PanResponder, | |
| ActivityIndicator, | |
| } from "react-native"; | |
| import { grpahql } from "apollo-client"; | |
| import gql from "graphql-tag"; | |
| import { Query, Mutation } from "react-apollo"; | |
| import { BlurView } from "react-native-blur"; | |
| import FadeIn from "react-native-fade-in-image"; | |
| import Icon from "react-native-vector-icons/Ionicons"; | |
| import { iOSUIKit, iOSColors } from "@vincentriemer/react-native-typography"; | |
| import MusicKit from "../modules/musicKit"; | |
| import MKControl from "./MKControl"; | |
| import { TRACK_ART_SIZE } from "./TrackList"; | |
| import MarqueeLabel from "./Marquee"; | |
| import DimensionsCtx from "../DimensionsCtx"; | |
| import { sizedArtUrl } from "../utils/sizedArtUrl"; | |
| import { UPDATE_QUEUE_INDEX } from "./QueueMutations"; | |
| import { TrackProgress } from "./TrackProgress"; | |
| import PlaybackState from "./PlaybackState"; | |
| import { PlayerDragIcon } from "./PlayerDragIcon"; | |
| const { PlaybackStates } = MusicKit; | |
| type MediaItem = { | |
| id: string, | |
| attributes: { | |
| albumName: string, | |
| artistName: string, | |
| artwork: { | |
| url: string, | |
| width?: number, | |
| }, | |
| name: string, | |
| durationInMillis: number, | |
| }, | |
| }; | |
| type MediaItemDidChangeEvent = { | |
| item: MediaItem, | |
| }; | |
| type PlaybackStateDidChangeEvent = { | |
| oldState: number, | |
| state: number, | |
| }; | |
| type PlaybackProgressDidChangeEvent = { | |
| progress: number, | |
| }; | |
| type PlaybackDurationDidChangeEvent = { | |
| duration: number, | |
| }; | |
| type QueuePositionDidChangeEvent = { | |
| position: number, | |
| }; | |
| const MINIMIZED_HEIGHT = 75; | |
| const ART_MAXIMIZED_TOP = 60; | |
| const ART_MINIMIZED_TOP = 10; | |
| type State = { | |
| artWidth: number, | |
| artHeight: number, | |
| artY: number, | |
| artContainerY: number, | |
| progress: number, | |
| isDragging: boolean, | |
| }; | |
| type Props = { | |
| isOpen: boolean, | |
| song: ?{ | |
| artistName: string, | |
| artwork: { url: string, width: ?number }, | |
| id: string, | |
| name: string, | |
| }, | |
| openAnim: Animated.Value, | |
| dragAnim: Animated.Value, | |
| updatePlayerIsOpen: Function, | |
| updateSelectedQueueIndex: Function, | |
| updatePlaybackState: Function, | |
| playbackState: number, | |
| }; | |
| class PlayerComponent extends React.PureComponent<Props, State> { | |
| _panResponder: *; | |
| playingAnim = new Animated.Value(1); | |
| constructor(props: Props) { | |
| super(props); | |
| this.state = { | |
| artX: -1, | |
| artY: -1, | |
| artWidth: -1, | |
| artHeight: -1, | |
| artContainerX: -1, | |
| artContainerY: -1, | |
| progress: 0, | |
| progressWidth: -1, | |
| isDragging: false, | |
| }; | |
| this._panResponder = PanResponder.create({ | |
| onMoveShouldSetPanResponder: (evt, gestureState) => { | |
| if (Math.abs(gestureState.dy) > 5 && this.props.isOpen) { | |
| this.setState({ isDragging: true }); | |
| return true; | |
| } else { | |
| return false; | |
| } | |
| }, | |
| onPanResponderMove: (evt, { dy }) => { | |
| this.props.dragAnim.setValue(dy); | |
| }, | |
| onPanResponderRelease: this._handleEnd, | |
| onPanResponderTerminate: this._handleEnd, | |
| }); | |
| } | |
| _subscriptions: ?Array<{ remove: () => void }>; | |
| componentDidMount() { | |
| const _subscriptions = []; | |
| _subscriptions.push( | |
| MusicKit.addListener( | |
| "playbackStateDidChange", | |
| this.handlePlaybackStateDidChange | |
| ) | |
| ); | |
| _subscriptions.push( | |
| MusicKit.addListener( | |
| "queuePositionDidChange", | |
| this.handleQueuePositionDidChange | |
| ) | |
| ); | |
| this._subscriptions = _subscriptions; | |
| } | |
| componentWillUnmount() { | |
| if (this._subscriptions) { | |
| this._subscriptions.forEach(sub => { | |
| sub.remove(); | |
| }); | |
| this._subscriptions = null; | |
| } | |
| } | |
| componentDidUpdate(prevProps: Props, prevState: State) { | |
| if ( | |
| prevProps.isOpen !== this.props.isOpen || | |
| this.isPlaying(prevProps.playbackState) !== | |
| this.isPlaying(this.props.playbackState) | |
| ) { | |
| const toValue = | |
| !this.props.isOpen || this.isPlaying(this.props.playbackState) | |
| ? 1 | |
| : 0.8; | |
| Animated.spring(this.playingAnim, { | |
| toValue, | |
| useNativeDriver: true, | |
| }).start(); | |
| } | |
| } | |
| handleQueuePositionDidChange = (evt: QueuePositionDidChangeEvent) => { | |
| this.props.updateSelectedQueueIndex({ | |
| variables: { selectedQueueIndex: evt.position }, | |
| }); | |
| }; | |
| handlePlaybackStateDidChange = (evt: PlaybackStateDidChangeEvent) => { | |
| this.props.updatePlaybackState({ | |
| variables: { playbackState: evt.state }, | |
| }); | |
| }; | |
| handleArtLayout = e => { | |
| const { x, y, width, height } = e.nativeEvent.layout; | |
| this.setState({ artY: y, artWidth: width, artHeight: height }); | |
| }; | |
| handleArtContainerLayout = e => { | |
| const { x, y } = e.nativeEvent.layout; | |
| this.setState({ artContainerY: y }); | |
| }; | |
| isPlaying = (playbackState: number) => { | |
| switch (playbackState) { | |
| case PlaybackStates.playing: | |
| case PlaybackStates.seeking: | |
| return true; | |
| default: | |
| return false; | |
| } | |
| }; | |
| isLoading = (playbackState: number) => { | |
| switch (playbackState) { | |
| case PlaybackStates.loading: | |
| case PlaybackStates.waiting: | |
| return true; | |
| default: | |
| return false; | |
| } | |
| }; | |
| _handleEnd = (evt, gestureState) => { | |
| this.setState({ isDragging: false }); | |
| if (this.props.isOpen) { | |
| const isClosing = gestureState.vy > 0.5 || gestureState.dy > 220; | |
| requestAnimationFrame(() => { | |
| if (isClosing) { | |
| this.props.updatePlayerIsOpen({ variables: { isOpen: false } }); | |
| } | |
| Animated.spring(this.props.dragAnim, { | |
| toValue: 0, | |
| speed: 10, | |
| bounciness: 0, | |
| useNativeDriver: true, | |
| }).start(); | |
| }); | |
| } | |
| }; | |
| render() { | |
| const { | |
| isOpen, | |
| updatePlayerIsOpen, | |
| openAnim, | |
| song, | |
| playbackState, | |
| } = this.props; | |
| const { artY, artWidth, artHeight, artContainerY, isDragging } = this.state; | |
| if (song == null) return null; | |
| const { name, artwork, artistName } = song; | |
| const hasLayout = !( | |
| artWidth === -1 || | |
| artHeight === -1 || | |
| artY === -1 || | |
| artContainerY === -1 | |
| ); | |
| const artMinimizedSize = MINIMIZED_HEIGHT - 2 * ART_MINIMIZED_TOP; | |
| const isPlaying = this.isPlaying(playbackState); | |
| return ( | |
| <View pointerEvents="box-none" style={StyleSheet.absoluteFill}> | |
| <Animated.View | |
| pointerEvents="none" | |
| style={[ | |
| StyleSheet.absoluteFill, | |
| styles.backgroundDim, | |
| { | |
| opacity: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [0.5, 0], | |
| }), | |
| }, | |
| ]} | |
| /> | |
| <DimensionsCtx.Consumer> | |
| {({ width, height }) => ( | |
| <Animated.View | |
| pointerEvents="auto" | |
| {...this._panResponder.panHandlers} | |
| style={[ | |
| styles.container, | |
| { | |
| opacity: hasLayout ? 1 : 0, | |
| transform: [ | |
| { | |
| translateY: Animated.add( | |
| openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [0, height - MINIMIZED_HEIGHT], | |
| }), | |
| this.props.dragAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [0, 0.2], | |
| }) | |
| ), | |
| }, | |
| ], | |
| }, | |
| ]} | |
| > | |
| <View | |
| style={{ | |
| width: "100%", | |
| height: "200%", | |
| paddingBottom: "100%", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <React.Fragment> | |
| <Animated.View | |
| style={[ | |
| styles.roundedBackground, | |
| { | |
| opacity: openAnim.interpolate({ | |
| inputRange: [0, 0.8, 1], | |
| outputRange: [1, 0.9, 0], | |
| }), | |
| transform: [ | |
| { | |
| translateY: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [height * 0.035, 0], | |
| }), | |
| }, | |
| ], | |
| }, | |
| ]} | |
| /> | |
| <Animated.View | |
| style={[ | |
| styles.sharpBackground, | |
| { | |
| opacity: openAnim.interpolate({ | |
| inputRange: [0, 0.1, 1], | |
| outputRange: [0, 0.9, 1], | |
| }), | |
| transform: [ | |
| { | |
| translateY: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [height * 0.035, 0], | |
| }), | |
| }, | |
| ], | |
| }, | |
| ]} | |
| > | |
| <BlurView | |
| style={{ flex: 1 }} | |
| blurType="xlight" | |
| blurAmount={10} | |
| /> | |
| </Animated.View> | |
| <View | |
| style={{ | |
| position: "absolute", | |
| width: "100%", | |
| top: height * 0.035 + 12, | |
| left: 0, | |
| alignItems: "center", | |
| }} | |
| > | |
| <PlayerDragIcon | |
| isOpen={isOpen} | |
| onPress={() => {}} | |
| isDragging={isDragging} | |
| dragAnim={this.props.dragAnim} | |
| openAnim={this.props.openAnim} | |
| /> | |
| </View> | |
| <View | |
| onLayout={this.handleArtContainerLayout} | |
| style={[ | |
| styles.artContainer, | |
| { | |
| flexBasis: Math.min(width * 0.95, 500), | |
| flexGrow: 0, | |
| flexShrink: 1, | |
| }, | |
| ]} | |
| > | |
| <Animated.View | |
| style={[ | |
| styles.art, | |
| { | |
| transform: [ | |
| { | |
| translateX: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [ | |
| 0, | |
| -(width / 2) + artWidth / 2 + 20, | |
| ], | |
| }), | |
| }, | |
| { | |
| translateY: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [ | |
| 0, | |
| ART_MINIMIZED_TOP - artY - artContainerY, | |
| ], | |
| }), | |
| }, | |
| { translateX: -(artWidth / 2) }, | |
| { translateY: -(artHeight / 2) }, | |
| { | |
| scale: openAnim.interpolate({ | |
| inputRange: [0, 1], | |
| outputRange: [1, artMinimizedSize / artWidth], | |
| }), | |
| }, | |
| { translateX: artWidth / 2 }, | |
| { translateY: artHeight / 2 }, | |
| { scale: this.playingAnim }, | |
| ], | |
| }, | |
| ]} | |
| onLayout={this.handleArtLayout} | |
| > | |
| <FadeIn | |
| key={artwork.url} | |
| placeholderStyle={{ backgroundColor: "transparent" }} | |
| pointerEvents="none" | |
| style={{ flex: 1 }} | |
| renderPlaceholderContent={ | |
| <Image | |
| style={{ flex: 1, borderRadius: 10 }} | |
| source={{ | |
| uri: sizedArtUrl( | |
| artwork.url, | |
| TRACK_ART_SIZE, | |
| artwork.width | |
| ), | |
| }} | |
| /> | |
| } | |
| > | |
| <Image | |
| style={{ flex: 1, borderRadius: 10 }} | |
| source={{ | |
| uri: sizedArtUrl(artwork.url, 500, artwork.width), | |
| }} | |
| /> | |
| </FadeIn> | |
| </Animated.View> | |
| </View> | |
| <Animated.View | |
| style={{ | |
| position: "absolute", | |
| top: 0, | |
| right: 0, | |
| width: width - artMinimizedSize - 70, | |
| height: MINIMIZED_HEIGHT, | |
| opacity: openAnim.interpolate({ | |
| inputRange: [0, 0.8, 1], | |
| outputRange: [0, 0, 1], | |
| }), | |
| flexDirection: "row", | |
| alignItems: "center", | |
| }} | |
| > | |
| <TouchableOpacity | |
| pointerEvents="auto" | |
| disabled={isOpen} | |
| style={{ | |
| position: "relative", | |
| left: -artMinimizedSize - 70, | |
| marginRight: -artMinimizedSize - 70, | |
| paddingLeft: artMinimizedSize + 40, | |
| flex: 1, | |
| height: "100%", | |
| justifyContent: "center", | |
| alignItems: "flex-start", | |
| }} | |
| onPress={() => { | |
| this.props.updatePlayerIsOpen({ | |
| variables: { isOpen: true }, | |
| }); | |
| }} | |
| > | |
| <Text numberOfLines={1} style={iOSUIKit.body}> | |
| {name} | |
| </Text> | |
| </TouchableOpacity> | |
| <TouchableOpacity disabled={isOpen}> | |
| {this.isLoading(playbackState) ? ( | |
| <ActivityIndicator | |
| style={{ paddingHorizontal: 15 }} | |
| size={20} | |
| color={iOSColors.pink} | |
| /> | |
| ) : ( | |
| <MKControl | |
| type={isPlaying ? "pause" : "play"} | |
| disabled={isOpen} | |
| style={{ flex: 0, paddingHorizontal: 15 }} | |
| > | |
| <Icon | |
| name={isPlaying ? "ios-pause" : "ios-play"} | |
| size={35} | |
| /> | |
| </MKControl> | |
| )} | |
| </TouchableOpacity> | |
| <TouchableOpacity | |
| disabled={isOpen} | |
| onPress={() => { | |
| MusicKit.next(); | |
| }} | |
| > | |
| <Icon | |
| style={{ paddingHorizontal: 15, paddingRight: 30 }} | |
| name="ios-fastforward" | |
| size={35} | |
| /> | |
| </TouchableOpacity> | |
| </Animated.View> | |
| <View | |
| style={{ | |
| flexShrink: 0, | |
| width: "100%", | |
| maxWidth: 600, | |
| alignSelf: "center", | |
| }} | |
| > | |
| {/* Progress Slider */} | |
| <TrackProgress /> | |
| <View style={{ marginBottom: 30, marginHorizontal: "7%" }}> | |
| {/* Song Title */} | |
| <MarqueeLabel | |
| containerStyle={{ alignSelf: "center" }} | |
| duration={15000} | |
| textStyle={iOSUIKit.title3Emphasized} | |
| > | |
| {name} | |
| </MarqueeLabel> | |
| {/* Artist Name */} | |
| <MarqueeLabel | |
| containerStyle={{ alignSelf: "center" }} | |
| duration={15000} | |
| textStyle={{ | |
| ...iOSUIKit.title3, | |
| color: iOSColors.pink, | |
| }} | |
| > | |
| {artistName} | |
| </MarqueeLabel> | |
| </View> | |
| {/* Controls */} | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| justifyContent: "center", | |
| alignItems: "center", | |
| marginBottom: 30, | |
| }} | |
| > | |
| <View | |
| style={{ | |
| flex: 1, | |
| flexDirection: "row", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| marginHorizontal: "17%", | |
| }} | |
| > | |
| <TouchableOpacity | |
| onPress={() => { | |
| MusicKit.previous(); | |
| }} | |
| > | |
| <Icon name="ios-rewind" size={45} /> | |
| </TouchableOpacity> | |
| <TouchableOpacity | |
| style={{ | |
| height: "100%", | |
| alignItems: "center", | |
| justifyCntent: "center", | |
| }} | |
| > | |
| {this.isLoading(playbackState) ? ( | |
| <ActivityIndicator | |
| style={{ height: 72 }} | |
| size={30} | |
| color={iOSColors.pink} | |
| /> | |
| ) : ( | |
| <MKControl | |
| style={{ flex: 0 }} | |
| type={isPlaying ? "pause" : "play"} | |
| > | |
| <Icon | |
| name={isPlaying ? "ios-pause" : "ios-play"} | |
| size={60} | |
| /> | |
| </MKControl> | |
| )} | |
| </TouchableOpacity> | |
| <TouchableOpacity | |
| onPress={() => { | |
| MusicKit.next(); | |
| }} | |
| > | |
| <Icon name="ios-fastforward" size={45} /> | |
| </TouchableOpacity> | |
| </View> | |
| </View> | |
| {/* Volume Control */} | |
| <View | |
| style={{ | |
| marginBottom: 50, | |
| flexDirection: "row", | |
| alignItems: "center", | |
| marginHorizontal: "7%", | |
| opacity: 0.5, | |
| }} | |
| > | |
| <Icon color="grey" name="ios-volume-mute" size={30} /> | |
| {/* TODO: Replace with slider */} | |
| <View | |
| style={{ | |
| flex: 1, | |
| height: 2, | |
| marginHorizontal: 15, | |
| backgroundColor: "lightgrey", | |
| borderRadius: 1.5, | |
| }} | |
| /> | |
| <Icon color="grey" name="ios-volume-up" size={30} /> | |
| </View> | |
| </View> | |
| </React.Fragment> | |
| </View> | |
| </Animated.View> | |
| )} | |
| </DimensionsCtx.Consumer> | |
| </View> | |
| ); | |
| } | |
| } | |
| const styles = StyleSheet.create({ | |
| container: { | |
| flex: 1, | |
| flexDirection: "column", | |
| }, | |
| backgroundDim: { | |
| backgroundColor: "black", | |
| }, | |
| sharpBackground: { | |
| position: "absolute", | |
| top: 0, | |
| left: 0, | |
| width: "100%", | |
| height: "100%", | |
| borderTopWidth: StyleSheet.hairlineWidth, | |
| borderTopColor: "rgba(204,204,204,1)", | |
| }, | |
| roundedBackground: { | |
| position: "absolute", | |
| top: 0, | |
| left: 0, | |
| width: "100%", | |
| height: "200%", | |
| backgroundColor: "white", | |
| borderRadius: 8, | |
| }, | |
| artContainer: { | |
| width: "100%", | |
| alignItems: "center", | |
| paddingTop: ART_MAXIMIZED_TOP, | |
| }, | |
| art: { | |
| height: "100%", | |
| aspectRatio: 1, | |
| shadowOpacity: 0.3, | |
| shadowRadius: 50, | |
| shadowOffset: { width: 0, height: 10 }, | |
| shadowColor: "black", | |
| borderRadius: 10, | |
| }, | |
| }); | |
| const GET_PLAYER_INFO = gql` | |
| query GetPlayerInfo { | |
| player @client { | |
| isOpen | |
| queue { | |
| id | |
| attributes { | |
| name | |
| artistName | |
| artwork { | |
| url | |
| width | |
| } | |
| } | |
| } | |
| selectedQueueIndex | |
| song { | |
| id | |
| name | |
| artistName | |
| artwork { | |
| url | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const UPDATE_PLAYER_IS_OPEN = gql` | |
| mutation updatePlayerIsOpen($isOpen: boolean!) { | |
| updatePlayerIsOpen(isOpen: $isOpen) @client | |
| } | |
| `; | |
| type PlayerProps = { | |
| dragAnim: Animated.Value, | |
| openAnim: Animated.Value, | |
| }; | |
| export const Player = ({ dragAnim, openAnim }: PlayerProps) => ( | |
| <Query query={GET_PLAYER_INFO}> | |
| {({ loading, error, data }) => { | |
| if (error || loading) return null; | |
| const song = (() => { | |
| const { queue, selectedQueueIndex } = data.player; | |
| if (queue && selectedQueueIndex != null) { | |
| const { attributes } = queue[selectedQueueIndex]; | |
| return attributes; | |
| } | |
| })(); | |
| return ( | |
| <Mutation mutation={UPDATE_PLAYER_IS_OPEN}> | |
| {updatePlayerIsOpen => ( | |
| <Mutation mutation={UPDATE_QUEUE_INDEX}> | |
| {updateSelectedQueueIndex => ( | |
| <PlaybackState.Query> | |
| {playbackState => ( | |
| <PlaybackState.Mutation> | |
| {updatePlaybackState => ( | |
| <PlayerComponent | |
| isOpen={data.player.isOpen} | |
| song={song} | |
| updatePlayerIsOpen={updatePlayerIsOpen} | |
| updateSelectedQueueIndex={updateSelectedQueueIndex} | |
| dragAnim={dragAnim} | |
| openAnim={openAnim} | |
| playbackState={playbackState} | |
| updatePlaybackState={updatePlaybackState} | |
| /> | |
| )} | |
| </PlaybackState.Mutation> | |
| )} | |
| </PlaybackState.Query> | |
| )} | |
| </Mutation> | |
| )} | |
| </Mutation> | |
| ); | |
| }} | |
| </Query> | |
| ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment