Created
April 14, 2019 20:33
-
-
Save vincentriemer/b092caa244cc9009a70ffd2d8f27e4b3 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