streaming tracks!

also managing the queue for playing from album view
This commit is contained in:
austinried 2021-06-29 15:48:21 +09:00
parent 666e1e3e69
commit 9e2740c84e
10 changed files with 278 additions and 96 deletions

View File

@ -1,7 +1,9 @@
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useAtomValue } from 'jotai/utils'; import { useAtomValue } from 'jotai/utils';
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { ScrollView, Text, useWindowDimensions, View, Image, Pressable, GestureResponderEvent } from 'react-native'; import { GestureResponderEvent, Image, Pressable, ScrollView, Text, useWindowDimensions, View } from 'react-native';
import { TrackPlayerEvents } from 'react-native-track-player';
import { useCurrentTrackId, useSetQueue } from '../../hooks/player';
import { albumAtomFamily } from '../../state/music'; import { albumAtomFamily } from '../../state/music';
import colors from '../../styles/colors'; import colors from '../../styles/colors';
import text from '../../styles/text'; import text from '../../styles/text';
@ -24,15 +26,21 @@ const Button: React.FC<{
title: string; title: string;
onPress: (event: GestureResponderEvent) => void; onPress: (event: GestureResponderEvent) => void;
}> = ({ title, onPress }) => { }> = ({ title, onPress }) => {
const [opacity, setOpacity] = useState(1);
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onPressIn={() => setOpacity(0.6)}
onPressOut={() => setOpacity(1)}
onLongPress={() => setOpacity(1)}
style={{ style={{
backgroundColor: colors.accent, backgroundColor: colors.accent,
paddingHorizontal: 24, paddingHorizontal: 24,
minHeight: 42, minHeight: 42,
justifyContent: 'center', justifyContent: 'center',
borderRadius: 1000, borderRadius: 1000,
opacity,
}} }}
> >
<Text style={{ ...text.button }}>{title}</Text> <Text style={{ ...text.button }}>{title}</Text>
@ -40,13 +48,90 @@ const Button: React.FC<{
); );
} }
const songEvents = [
TrackPlayerEvents.PLAYBACK_STATE,
TrackPlayerEvents.PLAYBACK_TRACK_CHANGED,
]
const SongItem: React.FC<{
id: string;
title: string
artist?: string;
onPress: (event: GestureResponderEvent) => void;
}> = ({ id, title, artist, onPress }) => {
const [opacity, setOpacity] = useState(1);
const currentTrackId = useCurrentTrackId();
return (
<View
style={{
marginTop: 20,
marginLeft: 4,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Pressable
onPress={onPress}
onPressIn={() => setOpacity(0.6)}
onPressOut={() => setOpacity(1)}
onLongPress={() => setOpacity(1)}
style={{
flex: 1,
opacity,
}}
>
<Text style={{
...text.songListTitle,
color: currentTrackId === id ? colors.accent : colors.text.primary,
}}>{title}</Text>
<Text style={text.songListSubtitle}>{artist}</Text>
</Pressable>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginLeft: 10,
}}>
{/* <Text style={text.songListSubtitle}>{secondsToTime(duration || 0)}</Text> */}
<Image
source={require('../../../res/star.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 10,
}}
/>
<Image
source={require('../../../res/more_vertical.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 12,
marginRight: 2,
}}
/>
</View>
</View>
);
}
const AlbumDetails: React.FC<{ const AlbumDetails: React.FC<{
id: string, id: string,
}> = ({ id }) => { }> = ({ id }) => {
const album = useAtomValue(albumAtomFamily(id)); const album = useAtomValue(albumAtomFamily(id));
const layout = useWindowDimensions(); const layout = useWindowDimensions();
const setQueue = useSetQueue();
const coverSize = layout.width - layout.width / 2; const coverSize = layout.width - layout.width / 2.5;
if (!album) {
return (
<Text style={text.paragraph}>No Album</Text>
);
}
return ( return (
<ScrollView <ScrollView
@ -61,7 +146,7 @@ const AlbumDetails: React.FC<{
<AlbumCover <AlbumCover
height={coverSize} height={coverSize}
width={coverSize} width={coverSize}
coverArtUri={album?.coverArtUri} coverArtUri={album.coverArtUri}
/> />
<Text style={{ <Text style={{
@ -69,7 +154,7 @@ const AlbumDetails: React.FC<{
marginTop: 12, marginTop: 12,
width: layout.width - layout.width / 8, width: layout.width - layout.width / 8,
textAlign: 'center', textAlign: 'center',
}}>{album?.name}</Text> }}>{album.name}</Text>
<Text style={{ <Text style={{
...text.itemSubtitle, ...text.itemSubtitle,
@ -78,14 +163,14 @@ const AlbumDetails: React.FC<{
marginBottom: 20, marginBottom: 20,
width: layout.width - layout.width / 8, width: layout.width - layout.width / 8,
textAlign: 'center', textAlign: 'center',
}}>{album?.artist}{album?.year ? `${album.year}` : ''}</Text> }}>{album.artist}{album.year ? `${album.year}` : ''}</Text>
<View style={{ <View style={{
flexDirection: 'row' flexDirection: 'row'
}}> }}>
<Button <Button
title='Play Album' title='Play Album'
onPress={() => null} onPress={() => setQueue(album.songs, album.songs[0].id)}
/> />
{/* <View style={{ width: 6, }}></View> {/* <View style={{ width: 6, }}></View>
<Button <Button
@ -99,49 +184,16 @@ const AlbumDetails: React.FC<{
marginTop: 20, marginTop: 20,
marginBottom: 30, marginBottom: 30,
}}> }}>
{album?.songs {album.songs
.sort((a, b) => (a.track as number) - (b.track as number)) .sort((a, b) => (a.track as number) - (b.track as number))
.map(s => ( .map(s => (
<View key={s.id} style={{ <SongItem
marginTop: 20, key={s.id}
marginLeft: 4, id={s.id}
flexDirection: 'row', title={s.title}
justifyContent: 'space-between', artist={s.artist}
alignItems: 'center', onPress={() => setQueue(album.songs, s.id)}
}}> />
<View style={{
flexShrink: 1,
}}>
<Text style={text.songListTitle}>{s.title}</Text>
<Text style={text.songListSubtitle}>{s.artist}</Text>
</View>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginLeft: 10,
}}>
{/* <Text style={text.songListSubtitle}>{secondsToTime(s.duration || 0)}</Text> */}
<Image
source={require('../../../res/star.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 10,
}}
/>
<Image
source={require('../../../res/more_vertical.png')}
style={{
height: 28,
width: 28,
tintColor: colors.text.secondary,
marginLeft: 12,
marginRight: 2,
}}
/>
</View>
</View>
))} ))}
</View> </View>

View File

@ -1,9 +1,10 @@
import React from 'react'; import React, { useState } from 'react';
import { Text, View, Image, Pressable } from 'react-native'; import { Text, View, Image, Pressable } from 'react-native';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import textStyles from '../../styles/text'; import textStyles from '../../styles/text';
import colors from '../../styles/colors'; import colors from '../../styles/colors';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import { useNavigation } from '@react-navigation/native';
const icons: {[key: string]: any} = { const icons: {[key: string]: any} = {
home: { home: {
@ -24,6 +25,57 @@ const icons: {[key: string]: any} = {
}, },
} }
const BottomTabButton: React.FC<{
routeKey: string;
label: string;
name: string;
isFocused: boolean;
img: { regular: number, fill: number };
navigation: any;
}> = ({ routeKey, label, name, isFocused, img, navigation }) => {
const [opacity, setOpacity] = useState(1);
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: routeKey,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(name);
}
};
return (
<Pressable
onPress={onPress}
onPressIn={() => setOpacity(0.6)}
onPressOut={() => setOpacity(1)}
style={{
alignItems: 'center',
flex: 1,
opacity,
}}
>
<FastImage
source={isFocused ? img.fill : img.regular}
style={{
height: 26,
width: 26,
}}
tintColor={isFocused ? colors.text.primary : colors.text.secondary}
/>
<Text style={{
...textStyles.xsmall,
color: isFocused ? colors.text.primary : colors.text.secondary,
}}>
{label}
</Text>
</Pressable>
);
}
const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigation }) => { const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigation }) => {
return ( return (
<View style={{ <View style={{
@ -43,46 +95,15 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
? options.title ? options.title
: route.name; : route.name;
const isFocused = state.index === index; return <BottomTabButton
const img = icons[options.icon]; key={route.key}
routeKey={route.key}
const onPress = () => { label={label}
const event = navigation.emit({ name={route.name}
type: 'tabPress', isFocused={state.index === index}
target: route.key, img={icons[options.icon]}
canPreventDefault: true, navigation={navigation}
}); />;
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={{
alignItems: 'center',
flex: 1,
}}
>
<FastImage
source={isFocused ? img.fill : img.regular}
style={{
height: 26,
width: 26,
}}
tintColor={isFocused ? colors.text.primary : colors.text.secondary}
/>
<Text style={{
...textStyles.xsmall,
color: isFocused ? colors.text.primary : colors.text.secondary,
}}>
{label}
</Text>
</Pressable>
);
})} })}
</View> </View>
); );

75
src/hooks/player.ts Normal file
View File

@ -0,0 +1,75 @@
import { useState } from "react";
import TrackPlayer, { STATE_NONE, STATE_STOPPED, Track, TrackPlayerEvents, useTrackPlayerEvents } from "react-native-track-player";
import { Song } from "../models/music";
function mapSongToTrack(song: Song): Track {
return {
id: song.id,
title: song.title,
artist: song.artist || 'Unknown Artist',
url: song.streamUri,
artwork: song.coverArtUri,
}
}
const currentTrackEvents = [
TrackPlayerEvents.PLAYBACK_STATE,
TrackPlayerEvents.PLAYBACK_TRACK_CHANGED,
TrackPlayerEvents.REMOTE_STOP,
]
export const useCurrentTrackId = () => {
const [currentTrackId, setCurrentTrackId] = useState<string | null>(null);
useTrackPlayerEvents(currentTrackEvents, async (event) => {
switch (event.type) {
case TrackPlayerEvents.PLAYBACK_STATE:
switch (event.state) {
case STATE_NONE:
case STATE_STOPPED:
setCurrentTrackId(null);
break;
}
break;
case TrackPlayerEvents.PLAYBACK_TRACK_CHANGED:
setCurrentTrackId(await TrackPlayer.getCurrentTrack());
break;
case TrackPlayerEvents.REMOTE_STOP:
setCurrentTrackId(null);
break;
default:
break;
}
});
return currentTrackId;
}
export const useSetQueue = () => {
return async (songs: Song[], playId?: string) => {
await TrackPlayer.reset();
const tracks = songs.map(mapSongToTrack);
if (!playId) {
await TrackPlayer.add(tracks);
} else if (playId === tracks[0].id) {
await TrackPlayer.add(tracks);
await TrackPlayer.play();
} else {
const playIndex = tracks.findIndex(t => t.id === playId);
const tracks1 = tracks.slice(0, playIndex);
const tracks2 = tracks.slice(playIndex);
console.log('tracks1: ' + JSON.stringify(tracks1.map(t => t.title)));
console.log('tracks2: ' + JSON.stringify(tracks2.map(t => t.title)));
await TrackPlayer.add(tracks2);
await TrackPlayer.play();
await TrackPlayer.add(tracks1, playId);
const queue = await TrackPlayer.getQueue();
console.log('queue: ' + JSON.stringify(queue.map(t => t.title)));
}
}
}

14
src/hooks/subsonic.ts Normal file
View File

@ -0,0 +1,14 @@
import { useAtomValue } from "jotai/utils"
import { activeServerAtom } from "../state/settings"
import { SubsonicApiClient } from "../subsonic/api";
export const useSubsonicApi = () => {
const activeServer = useAtomValue(activeServerAtom);
return () => {
if (!activeServer) {
return undefined;
}
return new SubsonicApiClient(activeServer);
}
}

View File

@ -42,6 +42,9 @@ export interface Song {
discNumber?: number; discNumber?: number;
created?: Date; created?: Date;
starred?: Date; starred?: Date;
streamUri: string;
coverArtUri?: string,
} }
export type DownloadedSong = { export type DownloadedSong = {

View File

@ -17,5 +17,7 @@ module.exports = async function() {
TrackPlayer.play(); TrackPlayer.play();
} }
}); });
TrackPlayer.addEventListener('remote-next', () => TrackPlayer.skipToNext().catch(() => {}));
TrackPlayer.addEventListener('remote-previous', () => TrackPlayer.skipToPrevious().catch(() => {}));
}; };

View File

@ -82,8 +82,12 @@ function mapAlbumID3(album: AlbumID3Element, client: SubsonicApiClient): Album {
} }
} }
function mapChild(child: ChildElement): Song { function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
return { ...child } return {
...child,
streamUri: client.streamUri({ id: child.id }),
coverArtUri: child.coverArt ? client.getCoverArtUri({ id: child.coverArt }) : undefined,
}
} }
function mapAlbumID3WithSongs( function mapAlbumID3WithSongs(
@ -93,6 +97,6 @@ function mapAlbumID3WithSongs(
): AlbumWithSongs { ): AlbumWithSongs {
return { return {
...mapAlbumID3(album, client), ...mapAlbumID3(album, client),
songs: songs.map(s => mapChild(s)), songs: songs.map(s => mapChildToSong(s, client)),
} }
} }

View File

@ -8,6 +8,6 @@ export default {
mid: '#191919', mid: '#191919',
low: '#000000', low: '#000000',
}, },
accent: '#c260e5', accent: '#b134db',
accentLow: '#50285e', accentLow: '#511c63',
} }

View File

@ -1,6 +1,6 @@
import { DOMParser } from 'xmldom'; import { DOMParser } from 'xmldom';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams } from './params'; import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams, StreamParams } from './params';
import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses'; import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses';
import { Server } from '../models/settings'; import { Server } from '../models/settings';
import paths from '../paths'; import paths from '../paths';
@ -193,4 +193,8 @@ export class SubsonicApiClient {
getCoverArtUri(params: GetCoverArtParams): string { getCoverArtUri(params: GetCoverArtParams): string {
return this.buildUrl('getCoverArt', params); return this.buildUrl('getCoverArt', params);
} }
streamUri(params: StreamParams): string {
return this.buildUrl('stream', params);
}
} }

View File

@ -62,3 +62,10 @@ export type GetCoverArtParams = {
id: string; id: string;
size?: string; size?: string;
} }
export type StreamParams = {
id: string;
maxBitRate?: number;
format?: string;
estimateContentLength?: boolean;
}