mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
streaming tracks!
also managing the queue for playing from album view
This commit is contained in:
parent
666e1e3e69
commit
9e2740c84e
@ -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,87 +48,52 @@ const Button: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const AlbumDetails: React.FC<{
|
||||
id: string,
|
||||
}> = ({ id }) => {
|
||||
const album = useAtomValue(albumAtomFamily(id));
|
||||
const layout = useWindowDimensions();
|
||||
const songEvents = [
|
||||
TrackPlayerEvents.PLAYBACK_STATE,
|
||||
TrackPlayerEvents.PLAYBACK_TRACK_CHANGED,
|
||||
]
|
||||
|
||||
const coverSize = layout.width - layout.width / 2;
|
||||
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 (
|
||||
<ScrollView
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'center',
|
||||
paddingTop: coverSize / 8,
|
||||
}}
|
||||
>
|
||||
<AlbumCover
|
||||
height={coverSize}
|
||||
width={coverSize}
|
||||
coverArtUri={album?.coverArtUri}
|
||||
/>
|
||||
|
||||
<Text style={{
|
||||
...text.title,
|
||||
marginTop: 12,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>{album?.name}</Text>
|
||||
|
||||
<Text style={{
|
||||
...text.itemSubtitle,
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>{album?.artist}{album?.year ? ` • ${album.year}` : ''}</Text>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row'
|
||||
}}>
|
||||
<Button
|
||||
title='Play Album'
|
||||
onPress={() => null}
|
||||
/>
|
||||
{/* <View style={{ width: 6, }}></View>
|
||||
<Button
|
||||
title='S'
|
||||
onPress={() => null}
|
||||
/> */}
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
width: layout.width - (layout.width / 20),
|
||||
marginTop: 20,
|
||||
marginBottom: 30,
|
||||
}}>
|
||||
{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>
|
||||
}}
|
||||
>
|
||||
<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(s.duration || 0)}</Text> */}
|
||||
{/* <Text style={text.songListSubtitle}>{secondsToTime(duration || 0)}</Text> */}
|
||||
<Image
|
||||
source={require('../../../res/star.png')}
|
||||
style={{
|
||||
@ -142,6 +115,85 @@ const AlbumDetails: React.FC<{
|
||||
/>
|
||||
</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.5;
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<Text style={text.paragraph}>No Album</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'center',
|
||||
paddingTop: coverSize / 8,
|
||||
}}
|
||||
>
|
||||
<AlbumCover
|
||||
height={coverSize}
|
||||
width={coverSize}
|
||||
coverArtUri={album.coverArtUri}
|
||||
/>
|
||||
|
||||
<Text style={{
|
||||
...text.title,
|
||||
marginTop: 12,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>{album.name}</Text>
|
||||
|
||||
<Text style={{
|
||||
...text.itemSubtitle,
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>{album.artist}{album.year ? ` • ${album.year}` : ''}</Text>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row'
|
||||
}}>
|
||||
<Button
|
||||
title='Play Album'
|
||||
onPress={() => setQueue(album.songs, album.songs[0].id)}
|
||||
/>
|
||||
{/* <View style={{ width: 6, }}></View>
|
||||
<Button
|
||||
title='S'
|
||||
onPress={() => null}
|
||||
/> */}
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
width: layout.width - (layout.width / 20),
|
||||
marginTop: 20,
|
||||
marginBottom: 30,
|
||||
}}>
|
||||
{album.songs
|
||||
.sort((a, b) => (a.track as number) - (b.track as number))
|
||||
.map(s => (
|
||||
<SongItem
|
||||
key={s.id}
|
||||
id={s.id}
|
||||
title={s.title}
|
||||
artist={s.artist}
|
||||
onPress={() => setQueue(album.songs, s.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
|
||||
@ -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
|
||||
return <BottomTabButton
|
||||
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>
|
||||
);
|
||||
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
75
src/hooks/player.ts
Normal 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
14
src/hooks/subsonic.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,9 @@ export interface Song {
|
||||
discNumber?: number;
|
||||
created?: Date;
|
||||
starred?: Date;
|
||||
|
||||
streamUri: string;
|
||||
coverArtUri?: string,
|
||||
}
|
||||
|
||||
export type DownloadedSong = {
|
||||
|
||||
@ -18,4 +18,6 @@ module.exports = async function() {
|
||||
}
|
||||
});
|
||||
|
||||
TrackPlayer.addEventListener('remote-next', () => TrackPlayer.skipToNext().catch(() => {}));
|
||||
TrackPlayer.addEventListener('remote-previous', () => TrackPlayer.skipToPrevious().catch(() => {}));
|
||||
};
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,6 @@ export default {
|
||||
mid: '#191919',
|
||||
low: '#000000',
|
||||
},
|
||||
accent: '#c260e5',
|
||||
accentLow: '#50285e',
|
||||
accent: '#b134db',
|
||||
accentLow: '#511c63',
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,3 +62,10 @@ export type GetCoverArtParams = {
|
||||
id: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export type StreamParams = {
|
||||
id: string;
|
||||
maxBitRate?: number;
|
||||
format?: string;
|
||||
estimateContentLength?: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user