Initial commit
This commit is contained in:
33
src/components/App.tsx
Normal file
33
src/components/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/screens/Albums/components/Album.tsx
Normal file
96
src/screens/Albums/components/Album.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/screens/Albums/components/Albums.tsx
Normal file
107
src/screens/Albums/components/Albums.tsx
Normal 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;
|
||||
18
src/screens/Albums/index.tsx
Normal file
18
src/screens/Albums/index.tsx
Normal 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;
|
||||
70
src/screens/Albums/types.ts
Normal file
70
src/screens/Albums/types.ts
Normal 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 };
|
||||
};
|
||||
|
||||
91
src/screens/Player/components/MediaControls.tsx
Normal file
91
src/screens/Player/components/MediaControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/screens/Player/components/NowPlaying.tsx
Normal file
33
src/screens/Player/components/NowPlaying.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/screens/Player/components/ProgressBar.tsx
Normal file
65
src/screens/Player/components/ProgressBar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
src/screens/Player/components/Queue.tsx
Normal file
40
src/screens/Player/components/Queue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/screens/Player/index.tsx
Normal file
22
src/screens/Player/index.tsx
Normal 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
16
src/screens/index.tsx
Normal 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
106
src/utility/JellyfinApi.ts
Normal 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`);
|
||||
}
|
||||
28
src/utility/useCurrentTrack.ts
Normal file
28
src/utility/useCurrentTrack.ts
Normal 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
24
src/utility/useQueue.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user