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 { RecoilRoot } from 'recoil';
import SplashPage from './src/components/SplashPage'; import SplashPage from './src/components/SplashPage';
import RootNavigator from './src/components/navigation/RootNavigator'; import RootNavigator from './src/components/navigation/RootNavigator';
import MusicManager from './src/components/MusicManager';
const App = () => ( const App = () => (
<RecoilRoot> <RecoilRoot>
<SplashPage> <SplashPage>
<MusicManager />
<NavigationContainer> <NavigationContainer>
<RootNavigator /> <RootNavigator />
</NavigationContainer> </NavigationContainer>

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { Button, FlatList, Text, View } from 'react-native'; import { Button, FlatList, Text, View } from 'react-native';
import { useRecoilValue, useResetRecoilState } from 'recoil'; import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
import { artistsState, useUpdateArtists } from '../state/artists';
import { Artist } from '../models/music'; import { Artist } from '../models/music';
import { artistsState, isLibraryRefreshingState, libraryRefreshState } from '../state/music';
const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => ( const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
<View> <View>
@ -32,7 +32,8 @@ const List = () => {
const ListPlusControls = () => { const ListPlusControls = () => {
const resetArtists = useResetRecoilState(artistsState); const resetArtists = useResetRecoilState(artistsState);
const updateArtists = useUpdateArtists(); const setLibraryRefresh = useSetRecoilState(libraryRefreshState);
const isLibraryRefreshing = useRecoilValue(isLibraryRefreshingState);
return ( return (
<View> <View>
@ -41,8 +42,9 @@ const ListPlusControls = () => {
onPress={resetArtists} onPress={resetArtists}
/> />
<Button <Button
title='Update from API' title='Refresh Library'
onPress={updateArtists} onPress={() => setLibraryRefresh(true)}
disabled={isLibraryRefreshing}
/> />
<List /> <List />
</View> </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 RNFS from 'react-native-fs';
import TrackPlayer, { Track } from 'react-native-track-player'; import TrackPlayer, { Track } from 'react-native-track-player';
import { musicDb, settingsDb } from '../clients'; import { musicDb, settingsDb } from '../clients';
import paths from '../paths';
async function mkdir(path: string): Promise<void> { async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path); const exists = await RNFS.exists(path);
@ -24,11 +25,9 @@ const SplashPage: React.FC<{}> = ({ children }) => {
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1)); const minSplashTime = new Promise(resolve => setTimeout(resolve, 1));
const prepare = async () => { const prepare = async () => {
const filesPath = RNFS.DocumentDirectoryPath; await mkdir(paths.imageCache);
await mkdir(paths.songCache);
await mkdir(`${filesPath}/image_cache`); await mkdir(paths.songs);
await mkdir(`${filesPath}/song_cache`);
await mkdir(`${filesPath}/songs`);
await musicDb.openDb(); await musicDb.openDb();
await settingsDb.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 LinearGradient from 'react-native-linear-gradient';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Album } from '../../models/music'; import { Album } from '../../models/music';
import { albumsState, useCoverArtUri, useUpdateAlbums } from '../../state/albums'; import { albumsState, useCoverArtUri } from '../../state/music';
import colors from '../../styles/colors'; import colors from '../../styles/colors';
import textStyles from '../../styles/text'; import textStyles from '../../styles/text';
import TopTabContainer from '../common/TopTabContainer'; import TopTabContainer from '../common/TopTabContainer';
@ -91,30 +91,13 @@ const AlbumListRenderItem: React.FC<{ item: Album }> = ({ item }) => (
const AlbumsList = () => { const AlbumsList = () => {
const albums = useRecoilValue(albumsState); 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 ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<FlatList <FlatList
data={Object.values(albums)} data={albums}
renderItem={AlbumListRenderItem} renderItem={AlbumListRenderItem}
keyExtractor={item => item.id} keyExtractor={item => item.id}
onRefresh={refresh}
refreshing={refreshing}
numColumns={3} numColumns={3}
removeClippedSubviews={true} removeClippedSubviews={true}
/> />

View File

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

View File

@ -7,7 +7,16 @@ export interface Artist {
export interface Album { export interface Album {
id: string; id: string;
artistId: string;
name: string; name: string;
starred?: Date; starred?: Date;
coverArt?: string; 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 { 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'; 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', key: 'albumsState',
default: selector({ default: selector({
key: 'albumsState/default', key: 'albumsState/default',
@ -16,44 +34,39 @@ export const albumsState = atom<{ [id: string]: Album }>({
({ onSet }) => { ({ onSet }) => {
onSet((newValue) => { onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) { if (!(newValue instanceof DefaultValue)) {
musicDb.updateAlbums(Object.values(newValue)); musicDb.updateAlbums(newValue);
} }
}); });
}, },
], ],
}); });
export const albumState = selectorFamily<Album, string>({ export const songsState = atom<Song[]>({
key: 'albumState', key: 'songsState',
get: id => ({ get }) => { default: selector({
return get(albumsState)[id]; key: 'songsState/default',
}, get: () => musicDb.getSongs(),
}),
effects_UNSTABLE: [
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
musicDb.updateSongs(newValue);
}
});
},
],
}); });
export const useUpdateAlbums = () => { export const libraryRefreshState = atom<boolean>({
const setAlbums = useSetRecoilState(albumsState); key: 'libraryRefreshState',
const server = useRecoilValue(activeServer); default: false,
});
return async () => { export const isLibraryRefreshingState = atom<boolean>({
if (!server) { key: 'isLibraryRefreshingState',
return; default: false,
} });
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 function useCoverArtUri(id?: string): string | undefined { export function useCoverArtUri(id?: string): string | undefined {
if (!id) { if (!id) {
@ -70,7 +83,7 @@ export function useCoverArtUri(id?: string): string | undefined {
return; return;
} }
const filePath = `${RNFS.DocumentDirectoryPath}/image_cache/${id}`; const filePath = `${paths.songCache}/${id}`;
const fileUri = `file://${filePath}`; const fileUri = `file://${filePath}`;
if (await RNFS.exists(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'; import { DbStorage } from './db';
export class MusicDb extends DbStorage { export class MusicDb extends DbStorage {
@ -19,11 +19,22 @@ export class MusicDb extends DbStorage {
tx.executeSql(` tx.executeSql(`
CREATE TABLE albums ( CREATE TABLE albums (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
artistId TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
starred TEXT, starred TEXT,
coverArt 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> { async getAlbums(): Promise<Album[]> {
const results = await this.executeSql(` return (await this.executeSql(`
SELECT * FROM albums SELECT * FROM albums;
WHERE id = ?; `))[0].rows.raw().map(x => ({
`, [id]);
const rows = results[0].rows.raw();
return rows.map(x => ({
id: x.id, id: x.id,
artistId: x.artistid,
name: x.name, name: x.name,
starred: x.starred ? new Date(x.starred) : undefined, starred: x.starred ? new Date(x.starred) : undefined,
coverArt: x.coverArt || 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> { async updateAlbums(albums: Album[]): Promise<void> {
@ -106,13 +94,15 @@ export class MusicDb extends DbStorage {
tx.executeSql(` tx.executeSql(`
INSERT INTO albums ( INSERT INTO albums (
id, id,
artistId,
name, name,
starred, starred,
coverArt coverArt
) )
VALUES (?, ?, ?, ?); VALUES (?, ?, ?, ?, ?);
`, [ `, [
a.id, a.id,
a.artistId,
a.name, a.name,
a.starred ? a.starred.toISOString() : null, a.starred ? a.starred.toISOString() : null,
a.coverArt || null a.coverArt || null
@ -120,4 +110,42 @@ export class MusicDb extends DbStorage {
} }
}); });
} }
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 { DOMParser } from 'xmldom';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
import { GetAlbumList2Params, GetAlbumListParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams } from './params'; import { GetAlbumList2Params, GetAlbumListParams, GetAlbumParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams, GetMusicDirectoryParams } from './params';
import { GetAlbumList2Response, GetAlbumListResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, SubsonicResponse } from './responses'; import { GetAlbumList2Response, GetAlbumListResponse, GetAlbumResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, GetMusicDirectoryResponse, SubsonicResponse } from './responses';
import { ServerSettings } from '../models/settings'; import { ServerSettings } from '../models/settings';
import paths from '../paths';
export class SubsonicApiError extends Error { export class SubsonicApiError extends Error {
method: string; method: string;
@ -146,6 +147,16 @@ export class SubsonicApiClient {
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml)); 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>> { async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
const xml = await this.apiGetXml('getArtistInfo', params); const xml = await this.apiGetXml('getArtistInfo', params);
return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml)); return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml));
@ -175,7 +186,7 @@ export class SubsonicApiClient {
// //
async getCoverArt(params: GetCoverArtParams): Promise<string> { 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); 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 { export class ChildElement {
id: string; id: string;
parent?: string; parent?: string;

View File

@ -15,6 +15,14 @@ export type GetArtistInfoParams = {
export type GetArtistInfo2Params = GetArtistInfoParams; export type GetArtistInfo2Params = GetArtistInfoParams;
export type GetMusicDirectoryParams = {
id: string;
}
export type GetAlbumParams = {
id: string;
}
// //
// Album/song lists // 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'; 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 // Album/song lists
// //