-
-
Save kahunamoore/72e97f9937ee8753e6bcaf76653572b0 to your computer and use it in GitHub Desktop.
Player code for my Apple Music clone
This file contains 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