impl songs, whole library refresh

This commit is contained in:
austinried 2021-06-25 14:26:54 +09:00
parent c8ed5bf5cb
commit 50be0a6f85
15 changed files with 317 additions and 146 deletions

View File

@ -3,10 +3,12 @@ import { NavigationContainer } from '@react-navigation/native';
import { RecoilRoot } from 'recoil';
import SplashPage from './src/components/SplashPage';
import RootNavigator from './src/components/navigation/RootNavigator';
import MusicManager from './src/components/MusicManager';
const App = () => (
<RecoilRoot>
<SplashPage>
<MusicManager />
<NavigationContainer>
<RootNavigator />
</NavigationContainer>

View File

@ -1,8 +1,8 @@
import React from 'react';
import { Button, FlatList, Text, View } from 'react-native';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { artistsState, useUpdateArtists } from '../state/artists';
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
import { Artist } from '../models/music';
import { artistsState, isLibraryRefreshingState, libraryRefreshState } from '../state/music';
const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
<View>
@ -32,7 +32,8 @@ const List = () => {
const ListPlusControls = () => {
const resetArtists = useResetRecoilState(artistsState);
const updateArtists = useUpdateArtists();
const setLibraryRefresh = useSetRecoilState(libraryRefreshState);
const isLibraryRefreshing = useRecoilValue(isLibraryRefreshingState);
return (
<View>
@ -41,8 +42,9 @@ const ListPlusControls = () => {
onPress={resetArtists}
/>
<Button
title='Update from API'
onPress={updateArtists}
title='Refresh Library'
onPress={() => setLibraryRefresh(true)}
disabled={isLibraryRefreshing}
/>
<List />
</View>

View File

@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, useWindowDimensions, View } from 'react-native';
import { useSetRecoilState, useRecoilValue, useRecoilState } from 'recoil';
import { Album, Artist, Song } from '../models/music';
import { albumsState, artistsState, isLibraryRefreshingState, libraryRefreshState, songsState } from '../state/music';
import { activeServer } from '../state/settings';
import colors from '../styles/colors';
import { SubsonicApiClient } from '../subsonic/api';
const RefreshManager = () => {
const setArtists = useSetRecoilState(artistsState);
const setAlbums = useSetRecoilState(albumsState);
const setSongs = useSetRecoilState(songsState);
const server = useRecoilValue(activeServer);
const [libraryRefresh, setLibraryRefresh] = useRecoilState(libraryRefreshState);
const [isLibraryRefreshing, setIsLibraryRefreshing] = useRecoilState(isLibraryRefreshingState);
const updateLibrary = async () => {
if (!libraryRefresh) {
return;
}
setLibraryRefresh(false);
if (isLibraryRefreshing) {
return;
}
setIsLibraryRefreshing(true);
if (!server) {
return;
}
const client = new SubsonicApiClient(server);
const artistsResponse = await client.getArtists();
const artists: Artist[] = artistsResponse.data.artists.map(x => ({
id: x.id,
name: x.name,
starred: x.starred,
coverArt: x.coverArt,
}));
setArtists(artists);
const albumsResponse = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 500 });
const albums: Album[] = albumsResponse.data.albums
.filter(x => x.artistId !== undefined)
.map(x => ({
id: x.id,
artistId: x.artistId as string,
name: x.name,
coverArt: x.coverArt,
}));
setAlbums(albums);
const songs: Song[] = [];
for (const album of albums) {
const songsResponse = await client.getAlbum({ id: album.id });
const albumSongs: Song[] = songsResponse.data.songs.map(x => ({
id: x.id,
albumId: album.id,
artistId: album.artistId,
name: x.title,
starred: x.starred,
}));
songs.push(...albumSongs);
}
setSongs(songs);
setIsLibraryRefreshing(false);
}
useEffect(() => {
updateLibrary();
});
return <></>;
}
const MusicManager = () => {
const isLibraryRefreshing = useRecoilValue(isLibraryRefreshingState);
const layout = useWindowDimensions();
const RefreshIndicator = () => (
<ActivityIndicator size={'large'} color={colors.accent} style={{
backgroundColor: colors.accentLow,
position: 'absolute',
left: layout.width / 2 - 18,
top: layout.height / 2 - 18,
elevation: 999,
}}/>
);
return (
<View>
{isLibraryRefreshing ? <RefreshIndicator /> : <></>}
<React.Suspense fallback={<></>}>
<RefreshManager />
</React.Suspense>
</View>
)
}
export default MusicManager;

View File

@ -3,6 +3,7 @@ import { Text, View } from 'react-native';
import RNFS from 'react-native-fs';
import TrackPlayer, { Track } from 'react-native-track-player';
import { musicDb, settingsDb } from '../clients';
import paths from '../paths';
async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path);
@ -24,11 +25,9 @@ const SplashPage: React.FC<{}> = ({ children }) => {
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1));
const prepare = async () => {
const filesPath = RNFS.DocumentDirectoryPath;
await mkdir(`${filesPath}/image_cache`);
await mkdir(`${filesPath}/song_cache`);
await mkdir(`${filesPath}/songs`);
await mkdir(paths.imageCache);
await mkdir(paths.songCache);
await mkdir(paths.songs);
await musicDb.openDb();
await settingsDb.openDb();

View File

@ -4,7 +4,7 @@ import FastImage from 'react-native-fast-image';
import LinearGradient from 'react-native-linear-gradient';
import { useRecoilValue } from 'recoil';
import { Album } from '../../models/music';
import { albumsState, useCoverArtUri, useUpdateAlbums } from '../../state/albums';
import { albumsState, useCoverArtUri } from '../../state/music';
import colors from '../../styles/colors';
import textStyles from '../../styles/text';
import TopTabContainer from '../common/TopTabContainer';
@ -91,30 +91,13 @@ const AlbumListRenderItem: React.FC<{ item: Album }> = ({ item }) => (
const AlbumsList = () => {
const albums = useRecoilValue(albumsState);
const updateAlbums = useUpdateAlbums();
const [refreshing, setRefreshing] = useState(false);
const refresh = async () => {
setRefreshing(true);
await updateAlbums();
setRefreshing(false);
}
useEffect(() => {
if (!refreshing && Object.keys(albums).length === 0) {
refresh();
}
});
return (
<View style={{ flex: 1 }}>
<FlatList
data={Object.values(albums)}
data={albums}
renderItem={AlbumListRenderItem}
keyExtractor={item => item.id}
onRefresh={refresh}
refreshing={refreshing}
numColumns={3}
removeClippedSubviews={true}
/>

View File

@ -2,9 +2,9 @@ import React from 'react';
import { Text, View, Image, FlatList } from 'react-native';
import { Artist } from '../../models/music';
import { useRecoilValue } from 'recoil';
import { artistsState } from '../../state/artists';
import textStyles from '../../styles/text';
import TopTabContainer from '../common/TopTabContainer';
import { artistsState } from '../../state/music';
const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
<View style={{

View File

@ -7,7 +7,16 @@ export interface Artist {
export interface Album {
id: string;
artistId: string;
name: string;
starred?: Date;
coverArt?: string;
}
export interface Song {
id: string;
albumId: string;
artistId: string;
name: string;
starred?: Date;
}

7
src/paths.ts Normal file
View File

@ -0,0 +1,7 @@
import RNFS from 'react-native-fs';
export default {
imageCache: `${RNFS.DocumentDirectoryPath}/image_cache`,
songCache: `${RNFS.DocumentDirectoryPath}/song_cache`,
songs: `${RNFS.DocumentDirectoryPath}/songs`,
};

View File

@ -1,42 +0,0 @@
import { atom, DefaultValue, selector, useRecoilValue, useSetRecoilState } from 'recoil';
import { SubsonicApiClient } from '../subsonic/api';
import { activeServer } from './settings'
import { Artist } from '../models/music';
import { musicDb } from '../clients';
export const artistsState = atom<Artist[]>({
key: 'artistsState',
default: selector({
key: 'artistsState/default',
get: () => musicDb.getArtists(),
}),
effects_UNSTABLE: [
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
musicDb.updateArtists(newValue);
}
});
}
],
});
export const useUpdateArtists = () => {
const setArtists = useSetRecoilState(artistsState);
const server = useRecoilValue(activeServer);
return async () => {
if (!server) {
return;
}
const client = new SubsonicApiClient(server);
const response = await client.getArtists();
setArtists(response.data.artists.map(x => ({
id: x.id,
name: x.name,
coverArt: x.coverArt,
})));
};
};

View File

@ -1,12 +1,30 @@
import { atom, DefaultValue, selector, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
import { SubsonicApiClient } from '../subsonic/api';
import { activeServer } from './settings'
import { Album } from '../models/music';
import { musicDb } from '../clients';
import { useEffect, useState } from 'react';
import { atom, DefaultValue, selector, selectorFamily, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { musicDb } from '../clients';
import { Album, Artist, Song } from '../models/music';
import paths from '../paths';
import { SubsonicApiClient } from '../subsonic/api';
import { activeServer } from './settings';
import RNFS from 'react-native-fs';
export const albumsState = atom<{ [id: string]: Album }>({
export const artistsState = atom<Artist[]>({
key: 'artistsState',
default: selector({
key: 'artistsState/default',
get: () => musicDb.getArtists(),
}),
effects_UNSTABLE: [
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
musicDb.updateArtists(newValue);
}
});
},
],
});
export const albumsState = atom<Album[]>({
key: 'albumsState',
default: selector({
key: 'albumsState/default',
@ -16,44 +34,39 @@ export const albumsState = atom<{ [id: string]: Album }>({
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
musicDb.updateAlbums(Object.values(newValue));
musicDb.updateAlbums(newValue);
}
});
},
],
});
export const albumState = selectorFamily<Album, string>({
key: 'albumState',
get: id => ({ get }) => {
return get(albumsState)[id];
},
export const songsState = atom<Song[]>({
key: 'songsState',
default: selector({
key: 'songsState/default',
get: () => musicDb.getSongs(),
}),
effects_UNSTABLE: [
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
musicDb.updateSongs(newValue);
}
});
},
],
});
export const useUpdateAlbums = () => {
const setAlbums = useSetRecoilState(albumsState);
const server = useRecoilValue(activeServer);
export const libraryRefreshState = atom<boolean>({
key: 'libraryRefreshState',
default: false,
});
return async () => {
if (!server) {
return;
}
const client = new SubsonicApiClient(server);
const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 50 });
const albums = response.data.albums.reduce((acc, x) => {
acc[x.id] = {
id: x.id,
name: x.name,
coverArt: x.coverArt,
};
return acc;
}, {} as { [id: string]: Album });
setAlbums(albums);
};
};
export const isLibraryRefreshingState = atom<boolean>({
key: 'isLibraryRefreshingState',
default: false,
});
export function useCoverArtUri(id?: string): string | undefined {
if (!id) {
@ -70,7 +83,7 @@ export function useCoverArtUri(id?: string): string | undefined {
return;
}
const filePath = `${RNFS.DocumentDirectoryPath}/image_cache/${id}`;
const filePath = `${paths.songCache}/${id}`;
const fileUri = `file://${filePath}`;
if (await RNFS.exists(filePath)) {

View File

@ -1,4 +1,4 @@
import { Album, Artist } from '../models/music';
import { Album, Artist, Song } from '../models/music';
import { DbStorage } from './db';
export class MusicDb extends DbStorage {
@ -19,11 +19,22 @@ export class MusicDb extends DbStorage {
tx.executeSql(`
CREATE TABLE albums (
id TEXT PRIMARY KEY NOT NULL,
artistId TEXT NOT NULL,
name TEXT NOT NULL,
starred TEXT,
coverArt TEXT
);
`);
tx.executeSql(`
CREATE TABLE songs (
id TEXT PRIMARY KEY NOT NULL,
albumId TEXT NOT NULL,
artistId TEXT NOT NULL,
name TEXT NOT NULL,
starred TEXT,
artist TEXT
);
`);
});
}
@ -62,39 +73,16 @@ export class MusicDb extends DbStorage {
});
}
async getAlbum(id: string): Promise<Album> {
const results = await this.executeSql(`
SELECT * FROM albums
WHERE id = ?;
`, [id]);
const rows = results[0].rows.raw();
return rows.map(x => ({
async getAlbums(): Promise<Album[]> {
return (await this.executeSql(`
SELECT * FROM albums;
`))[0].rows.raw().map(x => ({
id: x.id,
artistId: x.artistid,
name: x.name,
starred: x.starred ? new Date(x.starred) : undefined,
coverArt: x.coverArt || undefined,
}))[0];
}
async getAlbumIds(): Promise<string[]> {
return (await this.executeSql(`
SELECT id FROM albums;
`))[0].rows.raw().map(x => x.id);
}
async getAlbums(): Promise<{[id: string]: Album}> {
return (await this.executeSql(`
SELECT * FROM albums;
`))[0].rows.raw().reduce((acc, x) => {
acc[x.id] = {
id: x.id,
name: x.name,
starred: x.starred ? new Date(x.starred) : undefined,
coverArt: x.coverArt || undefined,
};
return acc;
}, {});
}));
}
async updateAlbums(albums: Album[]): Promise<void> {
@ -106,18 +94,58 @@ export class MusicDb extends DbStorage {
tx.executeSql(`
INSERT INTO albums (
id,
artistId,
name,
starred,
coverArt
)
VALUES (?, ?, ?, ?);
VALUES (?, ?, ?, ?, ?);
`, [
a.id,
a.name,
a.starred ? a.starred.toISOString() : null,
a.id,
a.artistId,
a.name,
a.starred ? a.starred.toISOString() : null,
a.coverArt || null
]);
}
});
}
async getSongs(): Promise<Song[]> {
return (await this.executeSql(`
SELECT * FROM songs;
`))[0].rows.raw().map(x => ({
id: x.id,
artistId: x.artistid,
albumId: x.albumId,
name: x.name,
starred: x.starred ? new Date(x.starred) : undefined,
}));
}
async updateSongs(songs: Song[]): Promise<void> {
await this.transaction((tx) => {
tx.executeSql(`
DELETE FROM songs
`);
for (const x of songs) {
tx.executeSql(`
INSERT INTO songs (
id,
artistId,
albumId,
name,
starred
)
VALUES (?, ?, ?, ?, ?);
`, [
x.id,
x.artistId,
x.albumId,
x.name,
x.starred ? x.starred.toISOString() : null,
]);
}
});
}
}

View File

@ -1,8 +1,9 @@
import { DOMParser } from 'xmldom';
import RNFS from 'react-native-fs';
import { GetAlbumList2Params, GetAlbumListParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams } from './params';
import { GetAlbumList2Response, GetAlbumListResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, SubsonicResponse } from './responses';
import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams } from './params';
import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses';
import { ServerSettings } from '../models/settings';
import paths from '../paths';
export class SubsonicApiError extends Error {
method: string;
@ -146,6 +147,16 @@ export class SubsonicApiClient {
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml));
}
async getMusicDirectory(params: GetMusicDirectoryParams): Promise<SubsonicResponse<GetMusicDirectoryResponse>> {
const xml = await this.apiGetXml('getMusicDirectory', params);
return new SubsonicResponse<GetMusicDirectoryResponse>(xml, new GetMusicDirectoryResponse(xml));
}
async getAlbum(params: GetAlbumParams): Promise<SubsonicResponse<GetAlbumResponse>> {
const xml = await this.apiGetXml('getAlbum', params);
return new SubsonicResponse<GetAlbumResponse>(xml, new GetAlbumResponse(xml));
}
async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
const xml = await this.apiGetXml('getArtistInfo', params);
return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml));
@ -175,7 +186,7 @@ export class SubsonicApiClient {
//
async getCoverArt(params: GetCoverArtParams): Promise<string> {
const path = `${RNFS.DocumentDirectoryPath}/image_cache/${params.id}`;
const path = `${paths.songCache}/${params.id}`;
return await this.apiDownload('getCoverArt', path, params);
}
}

View File

@ -72,6 +72,25 @@ export class ArtistElement extends BaseArtistElement {
}
}
export class DirectoryElement {
id: string;
parent?: string;
name: string;
starred?: Date;
userRating?: number;
averageRating?: number;
playCount?: number;
constructor(e: Element) {
this.id = requiredString(e, 'id');
this.parent = optionalString(e, 'parent');
this.name = requiredString(e, 'name');
this.starred = optionalDate(e, 'starred');
this.userRating = optionalInt(e, 'userRating');
this.averageRating = optionalFloat(e, 'averageRating');
}
}
export class ChildElement {
id: string;
parent?: string;

View File

@ -15,6 +15,14 @@ export type GetArtistInfoParams = {
export type GetArtistInfo2Params = GetArtistInfoParams;
export type GetMusicDirectoryParams = {
id: string;
}
export type GetAlbumParams = {
id: string;
}
//
// Album/song lists

View File

@ -1,4 +1,4 @@
import { AlbumID3Element, ArtistElement, ArtistID3Element, BaseArtistElement, ChildElement } from "./elements";
import { AlbumID3Element, ArtistElement, ArtistID3Element, BaseArtistElement, ChildElement, DirectoryElement } from "./elements";
export type ResponseStatus = 'ok' | 'failed';
@ -98,6 +98,34 @@ export class GetArtistInfo2Response extends BaseGetArtistInfoResponse<ArtistID3E
}
}
export class GetMusicDirectoryResponse {
directory: DirectoryElement;
children: ChildElement[] = [];
constructor(xml: Document) {
this.directory = new DirectoryElement(xml.getElementsByTagName('directory')[0]);
const childElements = xml.getElementsByTagName('child');
for (let i = 0; i < childElements.length; i++) {
this.children.push(new ChildElement(childElements[i]));
}
}
}
export class GetAlbumResponse {
album: AlbumID3Element;
songs: ChildElement[] = [];
constructor(xml: Document) {
this.album = new AlbumID3Element(xml.getElementsByTagName('album')[0]);
const childElements = xml.getElementsByTagName('song');
for (let i = 0; i < childElements.length; i++) {
this.songs.push(new ChildElement(childElements[i]));
}
}
}
//
// Album/song lists
//