Skip to content

Instantly share code, notes, and snippets.

@vincentriemer
Created April 14, 2019 20:33
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save vincentriemer/b092caa244cc9009a70ffd2d8f27e4b3 to your computer and use it in GitHub Desktop.
Save vincentriemer/b092caa244cc9009a70ffd2d8f27e4b3 to your computer and use it in GitHub Desktop.
Player code for my Apple Music clone
// @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