half decent album list with art

This commit is contained in:
austinried 2021-06-21 22:39:10 +09:00
parent ff94889644
commit b4fee0aff4
18 changed files with 1964 additions and 90 deletions

View File

@ -1,13 +1,16 @@
import React from 'react'; import React from 'react';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import SplashPage from './src/components/SplashPage';
import RootNavigator from './src/components/navigation/RootNavigator'; import RootNavigator from './src/components/navigation/RootNavigator';
const App = () => ( const App = () => (
<RecoilRoot> <RecoilRoot>
<NavigationContainer> <SplashPage>
<RootNavigator /> <NavigationContainer>
</NavigationContainer> <RootNavigator />
</NavigationContainer>
</SplashPage>
</RecoilRoot> </RecoilRoot>
); );

View File

@ -4,6 +4,7 @@ import android.app.Application;
import android.content.Context; import android.content.Context;
import com.facebook.react.PackageList; import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication; import com.facebook.react.ReactApplication;
import com.rnfs.RNFSPackage;
import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage; import com.facebook.react.ReactPackage;

View File

@ -1,3 +1,5 @@
rootProject.name = 'SubSonify' rootProject.name = 'SubSonify'
include ':react-native-fs'
project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app' include ':app'

View File

@ -12,6 +12,8 @@ target 'SubSonify' do
:hermes_enabled => false :hermes_enabled => false
) )
pod 'RNFS', :path => '../node_modules/react-native-fs'
target 'SubSonifyTests' do target 'SubSonifyTests' do
inherit! :complete inherit! :complete
# Pods for testing # Pods for testing

1576
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@
"md5": "^2.3.0", "md5": "^2.3.0",
"react": "17.0.1", "react": "17.0.1",
"react-native": "0.64.1", "react-native": "0.64.1",
"react-native-fs": "^2.18.0",
"react-native-gesture-handler": "^1.10.3", "react-native-gesture-handler": "^1.10.3",
"react-native-get-random-values": "^1.7.0", "react-native-get-random-values": "^1.7.0",
"react-native-linear-gradient": "^2.5.6", "react-native-linear-gradient": "^2.5.6",

BIN
res/record-l.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
res/record-m.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import RNFS from 'react-native-fs';
import { musicDb, settingsDb } from '../clients';
async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path);
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory();
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`);
} else {
return;
}
}
return await RNFS.mkdir(path);
}
const SplashPage: React.FC<{}> = ({ children }) => {
const [ready, setReady] = useState(false);
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 musicDb.openDb();
await settingsDb.openDb();
if (!(await musicDb.dbExists())) {
await musicDb.createDb();
}
if (!(await settingsDb.dbExists())) {
await settingsDb.createDb();
}
}
const promise = Promise.all([
prepare(), minSplashTime,
]);
useEffect(() => {
promise.then(() => {
setReady(true);
});
})
if (!ready) {
return <Text>Loading THE GOOD SHIT...</Text>
}
return (
<View style={{ flex: 1 }}>{children}</View>
);
}
export default SplashPage;

View File

@ -1,8 +1,117 @@
import React from 'react'; import React, { memo, useEffect, useState } from 'react';
import { View, Image, Text, FlatList, Button, ListRenderItem } from 'react-native';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Album } from '../../models/music';
import { albumsState, albumState, useUpdateAlbums, albumIdsState, useCoverArtUri } from '../../state/albums';
import TopTabContainer from '../common/TopTabContainer'; import TopTabContainer from '../common/TopTabContainer';
import textStyles from '../../styles/text';
import { ScrollView } from 'react-native-gesture-handler';
import colors from '../../styles/colors';
import LinearGradient from 'react-native-linear-gradient';
const AlbumArt: React.FC<{ height: number, width: number, id?: string }> = ({ height, width, id }) => {
const coverArtSource = useCoverArtUri(id);
return (
<LinearGradient
colors={[colors.accent, colors.accentLow]}
style={{
height, width,
backgroundColor: 'white',
}}
>
<Image
source={coverArtSource ? { uri: coverArtSource } : require('../../../res/record-m.png')}
style={{
height, width,
}}
/>
</LinearGradient>
)
}
const AlbumItem: React.FC<{ id: string } > = ({ id }) => {
const album = useRecoilValue(albumState(id));
// useEffect(() => {
// console.log(album.name);
// });
return (
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginVertical: 6,
marginLeft: 6,
// height: 200,
}}>
<AlbumArt
width={56}
height={56}
id={album.coverArt}
/>
<Text style={{
...textStyles.paragraph,
marginLeft: 12,
}}>{album.name}</Text>
</View>
);
}
const MemoAlbumItem = memo(AlbumItem, (prev, next) => {
// console.log('prev: ' + JSON.stringify(prev) + ' next: ' + JSON.stringify(next))
return prev.id == next.id;
});
const AlbumsList = () => {
const albumIds = useRecoilValue(albumIdsState);
const updateAlbums = useUpdateAlbums();
const [refreshing, setRefreshing] = useState(false);
const renderItem: React.FC<{ item: string }> = ({ item }) => (
<MemoAlbumItem id={item} />
);
const refresh = async () => {
setRefreshing(true);
await updateAlbums();
setRefreshing(false);
}
useEffect(() => {
if (!refreshing && albumIds.length === 0) {
refresh();
}
})
return (
<View style={{ flex: 1 }}>
{/* <Button
title='Update'
onPress={updateAlbums}
/> */}
<FlatList
data={albumIds}
renderItem={renderItem}
keyExtractor={item => item}
onRefresh={refresh}
refreshing={refreshing}
/>
{/* <ScrollView>
{Object.values(albums).map(item => (
<AlbumItem item={item} key={item.id} />
))}
</ScrollView> */}
</View>
);
}
const AlbumsTab = () => ( const AlbumsTab = () => (
<TopTabContainer> <TopTabContainer>
<React.Suspense fallback={<Text>Loading...</Text>}>
<AlbumsList />
</React.Suspense>
</TopTabContainer> </TopTabContainer>
); );

View File

@ -27,7 +27,7 @@ const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
</View> </View>
); );
const ArtistsTab = () => { const ArtistsList = () => {
const artists = useRecoilValue(artistsState); const artists = useRecoilValue(artistsState);
const renderItem: React.FC<{ item: Artist }> = ({ item }) => ( const renderItem: React.FC<{ item: Artist }> = ({ item }) => (
@ -35,14 +35,18 @@ const ArtistsTab = () => {
); );
return ( return (
<TopTabContainer> <FlatList
<FlatList data={artists}
data={artists} renderItem={renderItem}
renderItem={renderItem} keyExtractor={item => item.id}
keyExtractor={item => item.id} />
/>
</TopTabContainer>
); );
} }
const ArtistsTab = () => (
<TopTabContainer>
<ArtistsList />
</TopTabContainer>
);
export default ArtistsTab; export default ArtistsTab;

View File

@ -1,10 +1,15 @@
export interface Artist { export interface Artist {
id: string; id: string;
name: string; name: string;
starred?: Date;
coverArt?: string; coverArt?: string;
} }
export interface Album { export interface Album {
id: string; id: string;
name: string; name: string;
starred?: Date;
coverArt?: string;
coverArtPath?: string;
coverArtModified?: Date;
} }

View File

@ -1,10 +1,12 @@
import { atom, DefaultValue, selector, useRecoilValue, useSetRecoilState } from 'recoil'; import { atom, DefaultValue, selector, selectorFamily, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { SubsonicApiClient } from '../subsonic/api'; import { SubsonicApiClient } from '../subsonic/api';
import { activeServer } from './settings' import { activeServer } from './settings'
import { Artist } from '../models/music'; import { Album } from '../models/music';
import { musicDb } from '../clients'; import { musicDb } from '../clients';
import { useEffect, useState } from 'react';
import RNFS from 'react-native-fs';
export const albumsState = atom<Artist[]>({ export const albumsState = atom<{ [id: string]: Album }>({
key: 'albumsState', key: 'albumsState',
default: selector({ default: selector({
key: 'albumsState/default', key: 'albumsState/default',
@ -14,28 +16,99 @@ export const albumsState = atom<Artist[]>({
({ onSet }) => { ({ onSet }) => {
onSet((newValue) => { onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) { if (!(newValue instanceof DefaultValue)) {
musicDb.updateAlbums(newValue); musicDb.updateAlbums(Object.values(newValue));
} }
}); });
} },
], ],
}); });
// export const useUpdateAlbums = () => { export const albumIdsState = selector<string[]>({
// const setAlbums = useSetRecoilState(albumsState); key: 'albumIdsState',
// const server = useRecoilValue(activeServer); get: ({get}) => Object.keys(get(albumsState)),
});
// return async () => { export const albumState = selectorFamily<Album, string>({
// if (!server) { key: 'albumState',
// return; get: id => ({ get }) => {
// } return get(albumsState)[id];
},
// set: id => ({ set, get }, newValue) => {
// if (!(newValue instanceof DefaultValue)) {
// set(albumsState, prevState => ({ ...prevState, [id]: newValue }));
// }
// }
});
// const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt); export const useUpdateAlbums = () => {
// const response = await client.getAlbums(); const setAlbums = useSetRecoilState(albumsState);
const server = useRecoilValue(activeServer);
// setAlbums(response.data.albums.map(i => ({ return async () => {
// id: i.id, if (!server) {
// name: i.name, return;
// }))); }
// };
// }; const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt);
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 | undefined): string | undefined {
if (!id) {
return undefined;
}
const server = useRecoilValue(activeServer);
const [downloadAttempted, setdownloadAttempted] = useState(false);
const [coverArtSource, setCoverArtSource] = useState<string | undefined>(undefined);
const getCoverArt = async () => {
if (coverArtSource) {
return;
}
const filePath = `${RNFS.DocumentDirectoryPath}/image_cache/${id}`;
const fileUri = `file://${filePath}`;
if (await RNFS.exists(filePath)) {
// file already in cache, return the file
setCoverArtSource(fileUri);
return;
}
if (!server) {
// can't download without server set
return;
}
setdownloadAttempted(true);
if (downloadAttempted) {
// don't try to download more than once using this hook
return;
}
const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt);
await client.getCoverArt({ id });
setCoverArtSource(fileUri);
}
useEffect(() => {
getCoverArt();
});
return coverArtSource;
}

View File

@ -4,6 +4,7 @@ SQLite.enablePromise(true);
export abstract class DbStorage { export abstract class DbStorage {
private dbParams: DatabaseParams; private dbParams: DatabaseParams;
private db: SQLiteDatabase | undefined;
constructor(dbParams: DatabaseParams) { constructor(dbParams: DatabaseParams) {
this.dbParams = dbParams; this.dbParams = dbParams;
@ -36,36 +37,23 @@ export abstract class DbStorage {
return results[0].rows.length > 0; return results[0].rows.length > 0;
} }
private async openDb(): Promise<SQLiteDatabase> { async openDb(): Promise<void> {
return await SQLite.openDatabase({ ...this.dbParams }); this.db = await SQLite.openDatabase({ ...this.dbParams });
} }
async deleteDb(): Promise<void> { async deleteDb(): Promise<void> {
if (this.db) {
await this.db.close();
}
await SQLite.deleteDatabase({ ...this.dbParams }); await SQLite.deleteDatabase({ ...this.dbParams });
} }
async executeSql(sql: string, params?: any[]): Promise<[ResultSet]> { async executeSql(sql: string, params?: any[]): Promise<[ResultSet]> {
const db = await this.openDb(); // https://github.com/andpor/react-native-sqlite-storage/issues/410
try { return await (this.db as SQLiteDatabase).executeSql(sql, params);
// https://github.com/andpor/react-native-sqlite-storage/issues/410
return await db.executeSql(sql, params);
} catch (err) {
try { await db.close(); } catch {}
throw err;
} finally {
try { await db.close(); } catch {}
}
} }
async transaction(scope: (tx: Transaction) => void): Promise<void> { async transaction(scope: (tx: Transaction) => void): Promise<void> {
const db = await this.openDb(); await (this.db as SQLiteDatabase).transaction(scope);
try {
await db.transaction(scope);
} catch (err) {
try { await db.close(); } catch {}
throw err;
} finally {
try { await db.close(); } catch {}
}
} }
} }

View File

@ -20,7 +20,8 @@ export class MusicDb extends DbStorage {
CREATE TABLE albums ( CREATE TABLE albums (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
starred TEXT starred TEXT,
coverArt TEXT
); );
`); `);
}); });
@ -32,6 +33,7 @@ export class MusicDb extends DbStorage {
`))[0].rows.raw().map(x => ({ `))[0].rows.raw().map(x => ({
id: x.id, id: x.id,
name: x.name, name: x.name,
starred: x.starred ? new Date(x.starred) : undefined,
coverArt: x.coverArt || undefined, coverArt: x.coverArt || undefined,
})); }));
} }
@ -43,20 +45,56 @@ export class MusicDb extends DbStorage {
`); `);
for (const a of artists) { for (const a of artists) {
tx.executeSql(` tx.executeSql(`
INSERT INTO artists (id, name, starred, coverArt) INSERT INTO artists (
id,
name,
starred,
coverArt
)
VALUES (?, ?, ?, ?); VALUES (?, ?, ?, ?);
`, [a.id, a.name, null, a.coverArt || null]); `, [
a.id,
a.name,
a.starred ? a.starred.toISOString() : null,
a.coverArt || null
]);
} }
}); });
} }
async getAlbums(): Promise<Album[]> { async getAlbum(id: string): Promise<Album> {
return (await this.executeSql(` const results = await this.executeSql(`
SELECT * FROM albums; SELECT * FROM albums
`))[0].rows.raw().map(x => ({ WHERE id = ?;
`, [id]);
const rows = results[0].rows.raw();
return rows.map(x => ({
id: x.id, id: x.id,
name: x.name, 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> { async updateAlbums(albums: Album[]): Promise<void> {
@ -66,9 +104,19 @@ export class MusicDb extends DbStorage {
`); `);
for (const a of albums) { for (const a of albums) {
tx.executeSql(` tx.executeSql(`
INSERT INTO albums (id, name, starred) INSERT INTO albums (
VALUES (?, ?, ?); id,
`, [a.id, a.name, null]); name,
starred,
coverArt
)
VALUES (?, ?, ?, ?);
`, [
a.id,
a.name,
a.starred ? a.starred.toISOString() : null,
a.coverArt || null
]);
} }
}); });
} }

View File

@ -9,4 +9,5 @@ export default {
low: '#000000', low: '#000000',
}, },
accent: '#c260e5', accent: '#c260e5',
accentLow: '#50285e',
} }

View File

@ -1,5 +1,6 @@
import { DOMParser } from 'xmldom'; import { DOMParser } from 'xmldom';
import { GetAlbumList2Params, GetAlbumListParams, GetArtistInfo2Params, GetArtistInfoParams, GetIndexesParams } from './params'; 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 { GetAlbumList2Response, GetAlbumListResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, SubsonicResponse } from './responses';
export class SubsonicApiError extends Error { export class SubsonicApiError extends Error {
@ -35,7 +36,7 @@ export class SubsonicApiClient {
this.params.append('c', 'subsonify-cool-unique-app-string') this.params.append('c', 'subsonify-cool-unique-app-string')
} }
private async apiRequest(method: string, params?: {[key: string]: any}): Promise<Document> { private buildUrl(method: string, params?: {[key: string]: any}): string {
let query = this.params.toString(); let query = this.params.toString();
if (params) { if (params) {
const urlParams = this.obj2Params(params); const urlParams = this.obj2Params(params);
@ -45,10 +46,21 @@ export class SubsonicApiClient {
} }
const url = `${this.address}/rest/${method}?${query}`; const url = `${this.address}/rest/${method}?${query}`;
console.log(url); console.log(url);
return url;
}
const response = await fetch(url); private async apiDownload(method: string, path: string, params?: {[key: string]: any}): Promise<string> {
await RNFS.downloadFile({
fromUrl: this.buildUrl(method, params),
toFile: path,
}).promise;
return path;
}
private async apiGetXml(method: string, params?: {[key: string]: any}): Promise<Document> {
const response = await fetch(this.buildUrl(method, params));
const text = await response.text(); const text = await response.text();
console.log(text); console.log(text);
@ -80,7 +92,7 @@ export class SubsonicApiClient {
// //
async ping(): Promise<SubsonicResponse<null>> { async ping(): Promise<SubsonicResponse<null>> {
const xml = await this.apiRequest('ping'); const xml = await this.apiGetXml('ping');
return new SubsonicResponse<null>(xml, null); return new SubsonicResponse<null>(xml, null);
} }
@ -89,22 +101,22 @@ export class SubsonicApiClient {
// //
async getArtists(): Promise<SubsonicResponse<GetArtistsResponse>> { async getArtists(): Promise<SubsonicResponse<GetArtistsResponse>> {
const xml = await this.apiRequest('getArtists'); const xml = await this.apiGetXml('getArtists');
return new SubsonicResponse<GetArtistsResponse>(xml, new GetArtistsResponse(xml)); return new SubsonicResponse<GetArtistsResponse>(xml, new GetArtistsResponse(xml));
} }
async getIndexes(params?: GetIndexesParams): Promise<SubsonicResponse<GetIndexesResponse>> { async getIndexes(params?: GetIndexesParams): Promise<SubsonicResponse<GetIndexesResponse>> {
const xml = await this.apiRequest('getIndexes', params); const xml = await this.apiGetXml('getIndexes', params);
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml)); return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml));
} }
async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> { async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
const xml = await this.apiRequest('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));
} }
async getArtistInfo2(params: GetArtistInfo2Params): Promise<SubsonicResponse<GetArtistInfo2Response>> { async getArtistInfo2(params: GetArtistInfo2Params): Promise<SubsonicResponse<GetArtistInfo2Response>> {
const xml = await this.apiRequest('getArtistInfo2', params); const xml = await this.apiGetXml('getArtistInfo2', params);
return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml)); return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml));
} }
@ -113,12 +125,21 @@ export class SubsonicApiClient {
// //
async getAlbumList(params: GetAlbumListParams): Promise<SubsonicResponse<GetAlbumListResponse>> { async getAlbumList(params: GetAlbumListParams): Promise<SubsonicResponse<GetAlbumListResponse>> {
const xml = await this.apiRequest('getAlbumList', params); const xml = await this.apiGetXml('getAlbumList', params);
return new SubsonicResponse<GetAlbumListResponse>(xml, new GetAlbumListResponse(xml)); return new SubsonicResponse<GetAlbumListResponse>(xml, new GetAlbumListResponse(xml));
} }
async getAlbumList2(params: GetAlbumList2Params): Promise<SubsonicResponse<GetAlbumList2Response>> { async getAlbumList2(params: GetAlbumList2Params): Promise<SubsonicResponse<GetAlbumList2Response>> {
const xml = await this.apiRequest('getAlbumList2', params); const xml = await this.apiGetXml('getAlbumList2', params);
return new SubsonicResponse<GetAlbumList2Response>(xml, new GetAlbumList2Response(xml)); return new SubsonicResponse<GetAlbumList2Response>(xml, new GetAlbumList2Response(xml));
} }
//
// Media retrieval
//
async getCoverArt(params: GetCoverArtParams): Promise<string> {
const path = `${RNFS.DocumentDirectoryPath}/image_cache/${params.id}`;
return await this.apiDownload('getCoverArt', path, params);
}
} }

View File

@ -45,3 +45,12 @@ export type GetAlbumList2Params = {
} | GetAlbumList2TypeByYear | GetAlbumList2TypeByGenre; } | GetAlbumList2TypeByYear | GetAlbumList2TypeByGenre;
export type GetAlbumListParams = GetAlbumList2Params; export type GetAlbumListParams = GetAlbumList2Params;
//
// Media retrieval
//
export type GetCoverArtParams = {
id: string;
size?: string;
}