Initial commit

This commit is contained in:
Lei Nelissen
2020-06-16 17:51:51 +02:00
commit 50dd06a473
74 changed files with 14480 additions and 0 deletions

33
src/components/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import React, { Component } from 'react';
import TrackPlayer from 'react-native-track-player';
import { NavigationContainer } from '@react-navigation/native';
import Routes from '../screens';
interface State {
isReady: boolean;
}
export default class App extends Component<State> {
state = {
isReady: false
};
async componentDidMount() {
await TrackPlayer.setupPlayer();
this.setState({ isReady: true });
}
render() {
const { isReady } = this.state;
if (!isReady) {
return null;
}
return (
<NavigationContainer>
<Routes />
</NavigationContainer>
);
}
}

View File

@@ -0,0 +1,96 @@
import React, { Component, useCallback } from 'react';
import TrackPlayer from 'react-native-track-player';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParamList, AlbumTrack } from '../types';
import { Text, ScrollView, Dimensions, FlatList, Button, TouchableOpacity } from 'react-native';
import { retrieveAlbumTracks, getImage, generateTrack } from '../../../utility/JellyfinApi';
import styled from 'styled-components/native';
interface Props extends StackScreenProps<RootStackParamList, 'Album'> {
//
}
interface State {
tracks: AlbumTrack[];
}
const Screen = Dimensions.get('screen');
const AlbumImage = styled.Image`
border-radius: 10px;
width: ${Screen.width * 0.6}px;
height: ${Screen.width * 0.6}px;
margin: 10px auto;
`;
const TrackContainer = styled.View`
padding: 15px;
border-bottom-width: 1px;
border-bottom-color: #eee;
flex-direction: row;
`;
interface TouchableTrackProps {
id: string;
onPress: (id: string) => void;
}
const TouchableTrack: React.FC<TouchableTrackProps> = ({ id, onPress, children }) => {
const handlePress = useCallback(() => {
return onPress(id);
}, [id]);
return (
<TouchableOpacity onPress={handlePress}>
<TrackContainer>
{children}
</TrackContainer>
</TouchableOpacity>
);
};
export default class Album extends Component<Props, State> {
state: State = {
tracks: [],
}
async componentDidMount() {
const tracks = await retrieveAlbumTracks(this.props.route.params.id);
this.setState({ tracks });
}
selectTrack = async (id: string) => {
const track = await generateTrack(id);
await TrackPlayer.add([ track ]);
await TrackPlayer.skip(id);
TrackPlayer.play();
}
playAlbum = async () => {
const tracks = await Promise.all(this.state.tracks.map(track => generateTrack(track.Id)));
await TrackPlayer.removeUpcomingTracks();
await TrackPlayer.add(tracks);
await TrackPlayer.skip(tracks[0].id);
TrackPlayer.play();
}
render() {
const { tracks } = this.state;
const { album } = this.props.route.params;
return (
<ScrollView style={{ backgroundColor: '#f6f6f6', padding: 20, paddingBottom: 50 }}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<Text style={{ fontSize: 36, fontWeight: 'bold' }} >{album.Name}</Text>
<Text style={{ fontSize: 24, opacity: 0.5, marginBottom: 24 }}>{album.AlbumArtist}</Text>
<Button title="Play Album" onPress={this.playAlbum} />
{tracks.length ? tracks.map((track) =>
<TouchableTrack key={track.Id} id={track.Id} onPress={this.selectTrack}>
<Text style={{ width: 20, opacity: 0.5, marginRight: 5 }}>{track.IndexNumber}</Text>
<Text>{track.Name}</Text>
</TouchableTrack>
) : undefined}
</ScrollView>
);
}
}

View File

@@ -0,0 +1,107 @@
import React, { Component, useCallback } from 'react';
import { retrieveAlbums, getImage } from '../../../utility/JellyfinApi';
import { Album, RootStackParamList } from '../types';
import { Text, SafeAreaView, FlatList, Dimensions } from 'react-native';
import styled from 'styled-components/native';
import { TouchableOpacity } from 'react-native-gesture-handler';
import { StackNavigationProp } from '@react-navigation/stack';
interface Props {
navigation: StackNavigationProp<RootStackParamList, 'Albums'>;
}
interface State {
albums: Album[];
refreshing: boolean;
}
const Screen = Dimensions.get('screen');
const Container = styled.View`
/* flex-direction: row;
flex-wrap: wrap;
flex: 1; */
padding: 10px;
background-color: #f6f6f6;
`;
const AlbumItem = styled.View`
width: ${Screen.width / 2 - 10}px;
padding: 10px;
`;
const AlbumImage = styled.Image`
border-radius: 10px;
width: ${Screen.width / 2 - 40}px;
height: ${Screen.width / 2 - 40}px;
background-color: #fefefe;
margin-bottom: 5px;
`;
interface TouchableAlbumItemProps {
id: string;
onPress: (id: string) => void;
}
const TouchableAlbumItem: React.FC<TouchableAlbumItemProps> = ({ id, onPress, children }) => {
const handlePress = useCallback(() => {
return onPress(id);
}, [id]);
return (
<TouchableOpacity onPress={handlePress}>
<AlbumItem>
{children}
</AlbumItem>
</TouchableOpacity>
);
};
class Albums extends Component<Props, State> {
state: State = {
albums: [],
refreshing: false,
}
componentDidMount() {
this.retrieveData();
}
retrieveData = async () => {
this.setState({ refreshing: true });
const albums = await retrieveAlbums() as Album[];
this.setState({ albums, refreshing: false });
}
selectAlbum = (id: string) => {
const album = this.state.albums.find((d) => d.Id === id) as Album;
this.props.navigation.navigate('Album', { id, album });
}
render() {
const { albums, refreshing } = this.state;
return (
<SafeAreaView>
<Container>
<FlatList
data={albums}
keyExtractor={(item: Album) => item.Id}
refreshing={refreshing}
onRefresh={this.retrieveData}
numColumns={2}
renderItem={({ item }: { item: Album }) => (
<TouchableAlbumItem id={item.Id} onPress={this.selectAlbum}>
<AlbumImage source={{ uri: getImage(item.Id) }} />
<Text>{item.Name}</Text>
<Text style={{ opacity: 0.5 }}>{item.AlbumArtist}</Text>
</TouchableAlbumItem>
)}
/>
</Container>
</SafeAreaView>
);
}
}
export default Albums;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { RootStackParamList } from './types';
import Albums from './components/Albums';
import Album from './components/Album';
const Stack = createStackNavigator<RootStackParamList>();
function AlbumStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Albums" component={Albums} />
<Stack.Screen name="Album" component={Album} />
</Stack.Navigator>
);
}
export default AlbumStack;

View File

@@ -0,0 +1,70 @@
export interface UserData {
PlaybackPositionTicks: number;
PlayCount: number;
IsFavorite: boolean;
Played: boolean;
Key: string;
}
export interface ArtistItem {
Name: string;
Id: string;
}
export interface AlbumArtist {
Name: string;
Id: string;
}
export interface ImageTags {
Primary: string;
}
export interface Album {
Name: string;
ServerId: string;
Id: string;
SortName: string;
RunTimeTicks: number;
ProductionYear: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
PrimaryImageAspectRatio: number;
Artists: string[];
ArtistItems: ArtistItem[];
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
}
export interface AlbumTrack {
Name: string;
ServerId: string;
Id: string;
RunTimeTicks: number;
ProductionYear: number;
IndexNumber: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
Artists: string[];
ArtistItems: ArtistItem[];
Album: string;
AlbumId: string;
AlbumPrimaryImageTag: string;
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
}
export type RootStackParamList = {
Albums: undefined;
Album: { id: string, album: Album };
};

View File

@@ -0,0 +1,91 @@
import React from 'react';
import TrackPlayer, { usePlaybackState, STATE_PLAYING, STATE_PAUSED, Track } from 'react-native-track-player';
import { TouchableOpacity } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faPlay, faPause, faBackward, faForward } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/native';
import { useHasQueue } from '../../../utility/useQueue';
const MAIN_SIZE = 48;
const BUTTON_SIZE = 32;
const pause = () => TrackPlayer.pause();
const play = () => TrackPlayer.play();
const next = () => TrackPlayer.skipToNext();
const previous = () => TrackPlayer.skipToPrevious();
const Container = styled.View`
/* */
align-items: center;
margin: 20px 0;
`;
const Buttons = styled.View`
flex-direction: row;
align-items: center;
`;
const Button = styled.View`
margin: 20px;
`;
export default function MediaControls() {
return (
<Container>
<Buttons>
<Button>
<PreviousButton />
</Button>
<MainButton />
<Button>
<NextButton />
</Button>
</Buttons>
</Container>
);
}
export function PreviousButton() {
const hasQueue = useHasQueue();
return (
<TouchableOpacity onPress={previous} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
<FontAwesomeIcon icon={faBackward} size={BUTTON_SIZE} />
</TouchableOpacity>
);
}
export function NextButton() {
const hasQueue = useHasQueue();
return (
<TouchableOpacity onPress={next} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
<FontAwesomeIcon icon={faForward} size={BUTTON_SIZE} />
</TouchableOpacity>
);
}
export function MainButton() {
const state = usePlaybackState();
switch (state) {
case STATE_PLAYING:
return (
<TouchableOpacity onPress={pause}>
<FontAwesomeIcon icon={faPause} size={MAIN_SIZE} />
</TouchableOpacity>
);
case STATE_PAUSED:
return (
<TouchableOpacity onPress={play}>
<FontAwesomeIcon icon={faPlay} size={MAIN_SIZE} />
</TouchableOpacity>
);
default:
return (
<TouchableOpacity onPress={pause} disabled>
<FontAwesomeIcon icon={faPause} size={MAIN_SIZE} />
</TouchableOpacity>
);
}
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Text, Dimensions, Image, View } from 'react-native';
import useCurrentTrack from '../../../utility/useCurrentTrack';
import styled from 'styled-components/native';
const Screen = Dimensions.get('screen');
const Artwork = styled.Image`
border-radius: 10px;
background-color: #fbfbfb;
width: ${Screen.width * 0.8}px;
height: ${Screen.width * 0.8}px;
margin: 25px auto;
display: flex;
`;
export default function NowPlaying() {
const track = useCurrentTrack();
// GUARD: Don't render anything if nothing is playing
if (!track) {
return null;
}
return (
<View style={{ alignItems: 'center' }}>
<Artwork style={{ flex: 1 }} source={{ uri: track.artwork }} />
<Text style={{ fontWeight: 'bold', fontSize: 24, marginBottom: 12 }} >{track.artist}</Text>
<Text style={{ fontSize: 18, marginBottom: 12, textAlign: 'center', paddingLeft: 20, paddingRight: 20 }}>{track.title}</Text>
</View>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { useTrackPlayerProgress } from 'react-native-track-player';
import styled from 'styled-components/native';
import { View, Text } from 'react-native';
import { padStart } from 'lodash';
const Container = styled.View`
width: 100%;
margin-top: 10px;
background-color: #eeeeee;
position: relative;
`;
const Bar = styled.View<{ progress: number }>`
background-color: salmon;
height: 4px;
border-radius: 2px;
width: ${props => props.progress * 100}%;
`;
const PositionIndicator = styled.View<{ progress: number }>`
width: 20px;
height: 20px;
border-radius: 100px;
border: 1px solid #eee;
background-color: white;
transform: translateX(-10px) translateY(-8.5px);
position: absolute;
top: 0;
left: ${props => props.progress * 100}%;
box-shadow: 0px 4px 8px rgba(0,0,0,0.1);
`;
const NumberBar = styled.View`
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 20px 0;
`;
function getSeconds(seconds: number): string {
return padStart(String(Math.floor(seconds % 60).toString()), 2, '0');
}
function getMinutes(seconds: number): number {
return Math.floor(seconds / 60);
}
export default function ProgressBar() {
const { position, duration } = useTrackPlayerProgress(500);
return (
<>
<Container>
<Bar progress={position / duration} />
<PositionIndicator progress={position / duration} />
</Container>
<NumberBar>
<Text>0:00</Text>
<Text>{getMinutes(position)}:{getSeconds(position)}</Text>
<Text>{getMinutes(duration)}:{getSeconds(duration)}</Text>
</NumberBar>
</>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import useQueue from '../../../utility/useQueue';
import { View, Text } from 'react-native';
import styled, { css } from 'styled-components/native';
import useCurrentTrack from '../../../utility/useCurrentTrack';
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean }>`
padding: 10px;
border-bottom-width: 1px;
border-bottom-color: #eee;
${props => props.active && css`
font-weight: 900;
background-color: #ff8c6922;
padding: 20px 35px;
margin: 0 -25px;
`}
${props => props.alreadyPlayed && css`
opacity: 0.25;
`}
`;
export default function Queue() {
const queue = useQueue();
const currentTrack = useCurrentTrack();
const currentIndex = queue?.findIndex(d => d.id === currentTrack?.id);
return (
<View>
<Text style={{ marginTop: 20, marginBottom: 20 }}>Queue</Text>
{queue?.map((track, i) => (
<QueueItem active={currentTrack?.id === track.id} key={i} alreadyPlayed={i < currentIndex}>
<Text style={{marginBottom: 2}}>{track.title}</Text>
<Text style={{ opacity: 0.5 }}>{track.artist}</Text>
</QueueItem>
))}
</View>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import MediaControls from './components/MediaControls';
import ProgressBar from './components/ProgressBar';
import NowPlaying from './components/NowPlaying';
import styled from 'styled-components/native';
import Queue from './components/Queue';
const Container = styled.ScrollView`
background-color: #fff;
padding: 25px;
`;
export default function Player() {
return (
<Container>
<NowPlaying />
<MediaControls />
<ProgressBar />
<Queue />
</Container>
);
}

16
src/screens/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import { createBottomTabNavigator, BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
import Player from './Player';
import Albums from './Albums';
const Tab = createBottomTabNavigator();
export default function Routes() {
return (
<Tab.Navigator>
<Tab.Screen name="Now Playing" component={Player} />
<Tab.Screen name="Albums" component={Albums} />
</Tab.Navigator>
);
}

106
src/utility/JellyfinApi.ts Normal file
View File

@@ -0,0 +1,106 @@
import { Track } from 'react-native-track-player';
const JELLYFIN_SERVER = '***REMOVED***';
const API_KEY = '***REMOVED***';
const DEVICE_ID =
'***REMOVED***';
const USER_ID = '***REMOVED***';
const trackOptions: Record<string, string> = {
DeviceId: DEVICE_ID,
UserId: USER_ID,
api_key: API_KEY,
// Not sure where this number refers to, but setting it to 140000000 appears
// to do wonders for making stuff work
MaxStreamingBitrate: '140000000',
MaxSampleRate: '48000',
// This must be set to support client seeking
TranscodingProtocol: 'hls',
TranscodingContainer: 'ts',
// NOTE: We cannot send a comma-delimited list yet due to an issue with
// react-native-track-player. This is set to be merged and released very
// soon: https://github.com/react-native-kit/react-native-track-player/pull/950
// Container: 'mp3',
Container: 'mp3,aac,m4a,m4b|aac,alac,m4a,m4b|alac',
AudioCodec: 'aac',
static: 'true',
// These last few options appear to be redundant
// EnableRedirection: 'true',
// EnableRemoteMedia: 'false',
// // this should be generated client-side and is intended to be a unique value per stream URL
// PlaySessionId: Math.floor(Math.random() * 10000000).toString(),
// StartTimeTicks: '0',
};
const trackParams = new URLSearchParams(trackOptions).toString();
/**
* Generate a track object from a Jellyfin ItemId so that
* react-native-track-player can easily consume it.
*/
export async function generateTrack(ItemId: string): Promise<Track> {
// First off, fetch all the metadata for this particular track from the
// Jellyfin server
const track = await fetch(`${JELLYFIN_SERVER}/Users/${USER_ID}/Items/${ItemId}?api_key=${API_KEY}`)
.then(response => response.json());
// Also construct the URL for the stream
const url = encodeURI(`${JELLYFIN_SERVER}/Audio/${ItemId}/universal.mp3?${trackParams}`);
return {
id: ItemId,
url,
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
genre: Array.isArray(track.Genres) ? track.Genres[0] : undefined,
artwork: getImage(ItemId),
};
}
const albumOptions = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
api_key: API_KEY,
// StartIndex: '0',
// Limit: '100',
// ParentId: '7e64e319657a9516ec78490da03edccb',
};
const albumParams = new URLSearchParams(albumOptions).toString();
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAlbums() {
const albums = await fetch(`${JELLYFIN_SERVER}/Users/${USER_ID}/Items?${albumParams}`)
.then(response => response.json());
return albums.Items;
}
/**
* Retrieve a single album from the Emby server
*/
export async function retrieveAlbumTracks(ItemId: string) {
const singleAlbumOptions = {
ParentId: ItemId,
SortBy: 'SortName',
api_key: API_KEY,
};
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
const album = await fetch(`${JELLYFIN_SERVER}/Users/${USER_ID}/Items?${singleAlbumParams}`)
.then(response => response.json());
return album.Items;
}
export function getImage(ItemId: string): string {
return encodeURI(`${JELLYFIN_SERVER}/Items/${ItemId}/Images/Primary?format=jpeg`);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import TrackPlayer, { usePlaybackState, Track } from 'react-native-track-player';
/**
* This hook retrieves the current playing track from TrackPlayer
*/
export default function useCurrentTrack(): Track | undefined {
const state = usePlaybackState();
const [track, setTrack] = useState<Track>();
useEffect(() => {
const fetchTrack = async () => {
const currentTrackId = await TrackPlayer.getCurrentTrack();
// GUARD: Only fetch current track if there is a current track
if (!currentTrackId) {
return;
}
const currentTrack = await TrackPlayer.getTrack(currentTrackId);
setTrack(currentTrack);
};
fetchTrack();
}, [state]);
return track;
}

24
src/utility/useQueue.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import TrackPlayer, { usePlaybackState, Track } from 'react-native-track-player';
/**
* This hook retrieves the current playing track from TrackPlayer
*/
export default function useQueue(): Track[] | undefined {
const state = usePlaybackState();
const [queue, setQueue] = useState<Track[]>();
useEffect(() => {
TrackPlayer.getQueue().then(setQueue);
}, [state]);
return queue;
}
/**
* Shorthand helper to determine whether a queue exists
*/
export function useHasQueue(): boolean {
const queue = useQueue();
return !!queue && queue.length > 1;
}