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 { useAtomValue } from 'jotai/utils';
import React, { useEffect } from 'react';
import { ScrollView, Text, useWindowDimensions, View, Image, Pressable, GestureResponderEvent } from 'react-native';
import React, { useEffect, useState } from 'react';
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 colors from '../../styles/colors';
import text from '../../styles/text';
@ -24,15 +26,21 @@ const Button: React.FC<{
title: string;
onPress: (event: GestureResponderEvent) => void;
}> = ({ title, onPress }) => {
const [opacity, setOpacity] = useState(1);
return (
<Pressable
onPress={onPress}
onPressIn={() => setOpacity(0.6)}
onPressOut={() => setOpacity(1)}
onLongPress={() => setOpacity(1)}
style={{
backgroundColor: colors.accent,
paddingHorizontal: 24,
minHeight: 42,
justifyContent: 'center',
borderRadius: 1000,
opacity,
}}
>
<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<{
id: string,
}> = ({ id }) => {
const album = useAtomValue(albumAtomFamily(id));
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 (
<ScrollView
@ -61,7 +146,7 @@ const AlbumDetails: React.FC<{
<AlbumCover
height={coverSize}
width={coverSize}
coverArtUri={album?.coverArtUri}
coverArtUri={album.coverArtUri}
/>
<Text style={{
@ -69,7 +154,7 @@ const AlbumDetails: React.FC<{
marginTop: 12,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>{album?.name}</Text>
}}>{album.name}</Text>
<Text style={{
...text.itemSubtitle,
@ -78,14 +163,14 @@ const AlbumDetails: React.FC<{
marginBottom: 20,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>{album?.artist}{album?.year ? `${album.year}` : ''}</Text>
}}>{album.artist}{album.year ? `${album.year}` : ''}</Text>
<View style={{
flexDirection: 'row'
}}>
<Button
title='Play Album'
onPress={() => null}
onPress={() => setQueue(album.songs, album.songs[0].id)}
/>
{/* <View style={{ width: 6, }}></View>
<Button
@ -99,49 +184,16 @@ const AlbumDetails: React.FC<{
marginTop: 20,
marginBottom: 30,
}}>
{album?.songs
{album.songs
.sort((a, b) => (a.track as number) - (b.track as number))
.map(s => (
<View key={s.id} style={{
marginTop: 20,
marginLeft: 4,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<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>
<SongItem
key={s.id}
id={s.id}
title={s.title}
artist={s.artist}
onPress={() => setQueue(album.songs, s.id)}
/>
))}
</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 { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import textStyles from '../../styles/text';
import colors from '../../styles/colors';
import FastImage from 'react-native-fast-image';
import { useNavigation } from '@react-navigation/native';
const icons: {[key: string]: any} = {
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 }) => {
return (
<View style={{
@ -43,46 +95,15 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
? options.title
: route.name;
const isFocused = state.index === index;
const img = icons[options.icon];
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
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>
);
return <BottomTabButton
key={route.key}
routeKey={route.key}
label={label}
name={route.name}
isFocused={state.index === index}
img={icons[options.icon]}
navigation={navigation}
/>;
})}
</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;
created?: Date;
starred?: Date;
streamUri: string;
coverArtUri?: string,
}
export type DownloadedSong = {

View File

@ -18,4 +18,6 @@ module.exports = async function() {
}
});
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 {
return { ...child }
function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
return {
...child,
streamUri: client.streamUri({ id: child.id }),
coverArtUri: child.coverArt ? client.getCoverArtUri({ id: child.coverArt }) : undefined,
}
}
function mapAlbumID3WithSongs(
@ -93,6 +97,6 @@ function mapAlbumID3WithSongs(
): AlbumWithSongs {
return {
...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',
low: '#000000',
},
accent: '#c260e5',
accentLow: '#50285e',
accent: '#b134db',
accentLow: '#511c63',
}

View File

@ -1,6 +1,6 @@
import { DOMParser } from 'xmldom';
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 { Server } from '../models/settings';
import paths from '../paths';
@ -193,4 +193,8 @@ export class SubsonicApiClient {
getCoverArtUri(params: GetCoverArtParams): string {
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;
size?: string;
}
export type StreamParams = {
id: string;
maxBitRate?: number;
format?: string;
estimateContentLength?: boolean;
}