Merge pull request #123 from Chroma-Case/front/play-page

Front: play page
This commit is contained in:
Clément Le Bihan
2023-01-25 23:37:17 +09:00
committed by GitHub
16 changed files with 1131 additions and 929 deletions
+3 -1
View File
@@ -175,7 +175,9 @@ jobs:
context: ./front
push: true
tags: ${{steps.meta_front.outputs.tags}}
build-args: API_URL=${{secrets.API_URL}}
build-args: |
API_URL=${{secrets.API_URL}}
SCORO_URL=${{secrets.SCORO_URL}}
- name: Docker meta scorometer
id: meta_scorometer
uses: docker/metadata-action@v4
+2 -1
View File
@@ -35,7 +35,8 @@ services:
build:
context: ./front
args:
- API_URL=${API_URL}
- API_URL=${API_URL}
- SCORO_URL=${SCORO_URL}
ports:
- "80:80"
depends_on:
+2 -1
View File
@@ -34,6 +34,7 @@ export default class API {
method: params.method ?? 'GET'
});
const body = await response.text();
try {
const jsonResponse = body.length != 0 ? JSON.parse(body) : {};
if (!response.ok) {
@@ -196,7 +197,7 @@ export default class API {
*/
public static async getUserRecommendations(): Promise<Song[]> {
return Array.of(4).map((i) => ({
id: i,
id: 1,
name: `Recommended Song ${i}`,
artistId: i,
genreId: i,
+2
View File
@@ -12,6 +12,8 @@ RUN yarn global add sharp-cli@^2.1.0
COPY . .
ARG API_URL
ENV API_URL=$API_URL
ARG SCORO_URL
ENV SCORO_URL=$SCORO_URL
RUN expo build:web
+22 -13
View File
@@ -1,16 +1,19 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { useSelector } from './state/Store';
import { RootState, useSelector } from './state/Store';
import { translate } from './i18n/i18n';
import SongLobbyView from './views/SongLobbyView';
import AuthenticationView from './views/AuthenticationView';
import HomeView from './views/HomeView';
import SearchView from './views/SearchView';
import PartitionView from './views/PartitionView';
import SetttingsNavigator from './views/SettingsView';
import { useQuery } from 'react-query';
import API from './API';
import PlayView from './views/PlayView';
import ScoreView from './views/ScoreView';
import { Center } from 'native-base';
import LoadingComponent from './components/Loading';
import ProfileView from './views/ProfileView';
const Stack = createNativeStackNavigator();
@@ -19,9 +22,10 @@ export const protectedRoutes = <>
<Stack.Screen name="Home" component={HomeView} options={{ title: translate('welcome') }} />
<Stack.Screen name="Settings" component={SetttingsNavigator} options={{ title: 'Settings' }} />
<Stack.Screen name="Song" component={SongLobbyView} options={{ title: translate('play') }} />
<Stack.Screen name="Play" component={() => PlayView({ songId: 1 })} options={{ title: translate('play') }} />
<Stack.Screen name="Score" component={ScoreView} options={{ title: translate('score') }} />
<Stack.Screen name="Search" component={SearchView} options={{ title: translate('search') }} />
<Stack.Screen name="User" component={ProfileView} options={{ title: translate('user') }} />
<Stack.Screen name="Partition" component={PartitionView} options={{ title: translate('partition') }} />
</>;
export const publicRoutes = <React.Fragment>
@@ -29,20 +33,25 @@ export const publicRoutes = <React.Fragment>
</React.Fragment>;
export const Router = () => {
const isAuthentified = useSelector((state) => state.user.accessToken !== undefined);
const userProfile = useQuery(['user', 'me'], () => API.getUserInfo(), {
enabled: isAuthentified
const accessToken = useSelector((state: RootState) => state.user.accessToken);
const userProfile = useQuery(['user', 'me', accessToken], () => API.getUserInfo(), {
retry: 1,
refetchOnWindowFocus: false
});
if (userProfile.isLoading && !userProfile.data) {
return <Center style={{ flexGrow: 1 }}>
<LoadingComponent/>
</Center>
}
return (
<NavigationContainer>
{isAuthentified && !userProfile.isError
? <Stack.Navigator>
{protectedRoutes}
</Stack.Navigator>
: <Stack.Navigator>
{publicRoutes}
</Stack.Navigator>
<Stack.Navigator>
{ userProfile.isSuccess && accessToken
? protectedRoutes
: publicRoutes
}
</Stack.Navigator>
</NavigationContainer>
)
}
+1
View File
@@ -33,6 +33,7 @@ module.exports = {
},
"extra": {
apiUrl: process.env.API_URL,
scoroUrl: process.env.SCORO_URL,
"eas": {
"projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2"
}
@@ -10,9 +10,7 @@ type PartitionVisualizerProps = {
};
const PartitionVisualizer = ({ songId }: PartitionVisualizerProps) => {
const partitionRessources = useQuery(["partition"], () =>
API.getPartitionRessources(songId)
);
if (!partitionRessources.data) {
return (
@@ -22,7 +20,7 @@ const PartitionVisualizer = ({ songId }: PartitionVisualizerProps) => {
);
}
return (
<SlideView sources={partitionRessources.data} speed={200} startAt={0} />
);
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { Text } from "native-base";
import { translate } from "../i18n/i18n";
import { en } from "../i18n/Translations";
import { RootState, useSelector } from "../state/Store";
import { Text } from "native-base";
type TranslateProps = {
translationKey: keyof typeof en;
+23 -3
View File
@@ -6,6 +6,7 @@ export const en = {
changeLanguageBtn: "Change language",
searchBtn: "Search",
playBtn: 'Play',
playAgain: 'Play Again',
songPageBtn: 'Go to song page',
level: 'Level',
chapters: 'Chapters',
@@ -22,8 +23,10 @@ export const en = {
mySkillsToImprove: "My Competencies to work on",
recentlyPlayed: 'Recently played',
search: 'Search',
songsToGetBetter: 'Recommendations',
lastSearched: "Last searched",
levelProgress: 'good notes',
score: 'Score',
// profile page
user: 'Profile',
@@ -70,7 +73,10 @@ export const en = {
invalidEmail: 'Invalid email',
accountCreated: 'Account created',
loggedIn: 'Logged in',
precisionScore: "Precision",
goodNotesInARow: 'Good notes in a row',
usernameTaken: 'Username already taken',
goodNotes: 'good notes',
// categories
username: 'Username',
@@ -93,6 +99,7 @@ export const fr: typeof en = {
changeLanguageBtn: "Changer la langue",
searchBtn: "Rechercher",
playBtn: 'Jouer',
playAgain: 'Rejouer',
songPageBtn: 'Aller sur la page de la chanson',
level: 'Niveau',
chapters: 'Chapitres',
@@ -164,6 +171,11 @@ export const fr: typeof en = {
password: 'Mot de passe',
email: "Email",
repeatPassword: "Confirmer",
score: 'Score',
precisionScore: "Précision",
goodNotesInARow: 'Bonnes notes à la suite',
songsToGetBetter: 'Recommendations',
goodNotes: 'bonnes notes',
changepasswdBtn: 'Changer le mot de pass',
changeemailBtn: 'Changer l\'email',
googleacctBtn: 'Compte Google',
@@ -177,9 +189,17 @@ export const sp: typeof en = {
welcomeMessage: "Bienvenue",
signoutBtn: 'Desconectarse',
signinBtn: 'Connectarse',
changepasswdBtn: 'Changer le mot de pass',
changeemailBtn: 'Change l\'email',
googleacctBtn: 'Compte Google',
goodNotes: 'bonnes notes',
// competencies
changeLanguageBtn: "Changer de langue",
searchBtn: "Rechercher",
playBtn: "Jouer",
playAgain: 'Rejouer',
precisionScore: "Précision",
songPageBtn: "Chanson",
level: "Niveau",
chapters: "Chapitres",
@@ -246,15 +266,15 @@ export const sp: typeof en = {
accountCreated: 'Compte créé',
loggedIn: 'Connectado',
usernameTaken: 'Nombre de usuario ya tomado',
score: 'Score',
goodNotesInARow: 'Bonnes notes à la suite',
songsToGetBetter: 'Recommendations',
// categories
username: 'Nom d\'utilisateur',
password: 'Mot de passe',
email: 'Email',
repeatPassword: 'Répéter le mot de passe',
changepasswdBtn: 'Changer le mot de pass',
changeemailBtn: 'Change l\'email',
googleacctBtn: 'Compte Google',
partition: 'Partition',
};
+17 -14
View File
@@ -17,6 +17,7 @@
},
"dependencies": {
"@expo/vector-icons": "^13.0.0",
"@expo/webpack-config": "^0.17.3",
"@react-native-async-storage/async-storage": "^1.17.11",
"@react-navigation/native": "^6.0.11",
"@react-navigation/native-stack": "^6.7.0",
@@ -26,32 +27,34 @@
"@types/react-dom": "^18.0.6",
"@types/react-query": "^1.2.9",
"@types/react-test-renderer": "^18.0.0",
"expo": "~45.0.0",
"expo-asset": "~8.5.0",
"expo-dev-client": "~1.0.0",
"expo-secure-store": "~11.2.0",
"expo-splash-screen": "~0.15.1",
"expo-status-bar": "~1.3.0",
"expo-updates": "~0.13.4",
"expo": "^47.0.8",
"expo-asset": "~8.7.0",
"expo-dev-client": "~2.0.1",
"expo-screen-orientation": "^5.0.1",
"expo-secure-store": "~12.0.0",
"expo-splash-screen": "~0.17.5",
"expo-status-bar": "~1.4.2",
"format-duration": "^2.0.0",
"i18next": "^21.8.16",
"install": "^0.13.0",
"jest": "^26.6.3",
"jest-expo": "^45.0.1",
"moti": "^0.22.0",
"native-base": "^3.4.17",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "18.1.0",
"react-dom": "18.1.0",
"react-i18next": "^11.18.3",
"react-native": "0.68.2",
"react-native": "0.70.5",
"react-native-paper": "^4.12.5",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "~3.18.0",
"react-native-reanimated": "~2.8.0",
"react-native-safe-area-context": "4.2.4",
"react-native-screens": "~3.11.1",
"react-native-super-grid": "^4.6.1",
"react-native-svg": "12.3.0",
"react-native-svg": "13.4.0",
"react-native-testing-library": "^6.0.0",
"react-native-web": "0.17.7",
"react-native-web": "~0.18.9",
"react-redux": "^8.0.2",
"react-timer-hook": "^3.0.5",
"redux-persist": "^6.0.0",
"yup": "^0.32.11"
},
-3
View File
@@ -102,9 +102,6 @@ const HomeView = () => {
<Button backgroundColor={theme.colors.primary[600]} rounded={"full"} size="sm" onPress={() => navigation.navigate('Settings')} >
<Translate translationKey='settingsBtn'/>
</Button>
<Button backgroundColor={theme.colors.primary[600]} rounded={"full"} size="sm" onPress={() => navigation.navigate('Partition')} >
<Translate translationKey='partition'/>
</Button>
</Box>
</VStack>
</Box>
-15
View File
@@ -1,15 +0,0 @@
import React from "react";
import { Box } from "native-base";
import API from "../API";
import PartitionVisualizer from "../components/PartitionVisualizer/PartitionVisualizer";
const PartitionView = () => {
return (
<Box style={{ padding: 10 }}>
<PartitionVisualizer songId={1} />
</Box>
);
};
export default PartitionView;
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from 'react';
import { SafeAreaView, Text } from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
import { Box, Center, Column, IconButton, Progress, Row, View, useToast } from 'native-base';
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from '@react-navigation/native';
import { useQuery } from 'react-query';
import API from '../API';
import LoadingComponent from '../components/Loading';
import Constants from 'expo-constants';
import { useStopwatch } from 'react-timer-hook';
import PartitionVisualizer from '../components/PartitionVisualizer/PartitionVisualizer';
import SlideView from '../components/PartitionVisualizer/SlideView';
type PlayViewProps = {
songId: number
}
const PlayView = ({ songId }: PlayViewProps) => {
const navigation = useNavigation();
const song = useQuery(['song'], () => API.getSong(songId));
const toast = useToast();
const webSocket = useRef<WebSocket>();
const timer = useStopwatch({ autoStart: false });
const [paused, setPause] = useState<boolean>();
const partitionRessources = useQuery(["partition"], () =>
API.getPartitionRessources(songId)
);
const onPause = () => {
timer.pause();
setPause(true);
webSocket.current?.send(JSON.stringify({
type: "pause",
paused: true,
time: Date.now()
}));
}
const onResume = () => {
setPause(false);
timer.start();
webSocket.current?.send(JSON.stringify({
type: "pause",
paused: false,
time: Date.now()
}));
}
const onEnd = () => {
webSocket.current?.close();
}
const onMIDISuccess = (access) => {
const inputs = access.inputs;
webSocket.current?.send(JSON.stringify({
type: "start",
paused: false,
time: Date.now()
}));
if (inputs.size < 2) {
toast.show({ description: 'No MIDI Keyboard found' });
return;
}
toast.show({ description: `MIDI ready!`, placement: 'top' });
let inputIndex = 0;
webSocket.current = new WebSocket(Constants.manifest?.extra?.scoroUrl);
webSocket.current.onopen = () => {
webSocket.current!.send(JSON.stringify({
type: "start",
name: "clair-de-lune" /*song.data.id*/,
}));
timer.start();
};
webSocket.current.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data.type == 'end') {
navigation.navigate('Score');
} else if (data.song_launched == undefined) {
toast.show({ description: data, placement: 'top', colorScheme: 'secondary' });
}
} catch {
}
}
setPause(false);
inputs.forEach((input) => {
if (inputIndex != 0) {
return;
}
input.onmidimessage = (message) => {
const keyIsPressed = message.data[2] == 100;
const keyCode = message.data[1];
webSocket.current?.send(JSON.stringify({
type: keyIsPressed ? "note_on" : "note_off",
node: keyCode,
intensity: null,
time: Date.now()
}))
}
inputIndex++;
});
}
const onMIDIFailure = () => {
toast.show({ description: `Failed to get MIDI access` });
}
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIFailure);
return () => {
ScreenOrientation.unlockAsync().catch(() => {});
clearInterval(timer);
onEnd();
}
}, [])
const score = 20;
if (!song.data || !partitionRessources.data) {
return <Center style={{ flexGrow: 1 }}>
<LoadingComponent/>
</Center>
}
return (
<SafeAreaView style={{ flexGrow: 1, flexDirection: 'column' }}>
<View style={{ flexGrow: 1 }}>
<SlideView sources={partitionRessources.data} speed={200} startAt={0} />
</View>
<Box shadow={4} style={{ height: '12%', width:'100%', borderWidth: 0.5, margin: 5 }}>
<Row justifyContent='space-between' style={{ flexGrow: 1, alignItems: 'center' }} >
<Column space={2} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontWeight: 'bold' }}>Score: {score}%</Text>
<Progress value={score} style={{ width: '90%' }}/>
</Column>
<Center style={{ flex: 1, alignItems: 'center' }}>
<Text style={{ fontWeight: '700' }}>Rolling in the Deep</Text>
</Center>
<Row style={{ flex: 1, height: '100%', justifyContent: 'space-evenly', alignItems: 'center' }}>
<IconButton size='sm' colorScheme='secondary' variant='solid' _icon={{
as: Ionicons,
name: "play-back"
}}/>
<IconButton size='sm' variant='solid' _icon={{
as: Ionicons,
name: paused === false ? "pause" : "play"
}} onPress={() => {
if (paused == true) {
onResume();
} else if (paused === false) {
onPause();
}
}}/>
<Text>{timer.minutes}:{timer.seconds.toString().padStart(2, '0')}</Text>
<IconButton size='sm' colorScheme='coolGray' variant='solid' _icon={{
as: Ionicons,
name: "stop"
}} onPress={() => navigation.navigate('Score')}/>
</Row>
</Row>
</Box>
</SafeAreaView>
);
}
export default PlayView
+70
View File
@@ -0,0 +1,70 @@
import { Box, Button, Card, Column, Image, Progress, Row, Text, View, useTheme } from "native-base"
import Translate from "../components/Translate";
import SongCardGrid from "../components/SongCardGrid";
import { useNavigation } from "@react-navigation/native";
import { CardBorderRadius } from "../components/Card";
const ScoreView = (/*{ songId }, { songId: number }*/) => {
const theme = useTheme();
const navigation = useNavigation();
// const songQuery = useQuery(['song', props.songId], () => API.getSong(props.songId));
// const songScoreQuery = useQuery(['song', props.songId, 'score', 'latest'], () => API.getLastSongPerformanceScore(props.songId));
// const perfoamnceRecommandationsQuery = useQuery(['song', props.songId, 'score', 'latest', 'recommendations'], () => API.getLastSongPerformanceScore(props.songId));
return <Column style={{ flexGrow: 1, justifyContent: 'space-evenly', alignItems: 'center', padding: 10 }}>
<Text bold fontSize='lg'>Rolling in the Deep</Text>
<Text bold>Adele - 3:45</Text>
<Row style={{ flexGrow: 0.5, justifyContent: 'center' }}>
<Card shadow={3} style={{ aspectRatio: 1 }}>
<Image
style={{ zIndex: 0, aspectRatio: 1, margin: 5, borderRadius: CardBorderRadius}}
source={{ uri: 'https://imgs.search.brave.com/AinqAz0knOSOt0V3rcv7ps7aMVCo0QQfZ-1NTdwVjK0/rs:fit:1200:1200:1/g:ce/aHR0cDovLzEuYnAu/YmxvZ3Nwb3QuY29t/Ly0xTmZtZTdKbDVk/US9UaHd0Y3pieEVa/SS9BQUFBQUFBQUFP/TS9QdGx6ZWtWd2Zt/ay9zMTYwMC9BZGVs/ZSstKzIxKyUyNTI4/T2ZmaWNpYWwrQWxi/dW0rQ292ZXIlMjUy/OS5qcGc' }}
/>
</Card>
<Card shadow={3} style={{ aspectRatio: 1 }}>
<Column style={{ justifyContent: 'space-evenly', flexGrow: 1 }}>
<Row style={{ alignItems: 'center' }}>
<Text bold fontSize='xl'>
80
</Text>
<Translate translationKey='goodNotes' format={(t) => ' ' + t}/>
</Row>
<Row style={{ alignItems: 'center' }}>
<Text bold fontSize='xl'>
80
</Text>
<Translate translationKey='goodNotesInARow' format={(t) => ' ' + t}/>
</Row>
<Row style={{ alignItems: 'center' }}>
<Translate translationKey='precisionScore' format={(t) => t + ' : '}/>
<Text bold fontSize='xl'>
{"80" + "%"}
</Text>
</Row>
</Column>
{/* Precision */}
</Card>
</Row>
<SongCardGrid
heading={<Text fontSize='sm'>
<Translate translationKey="songsToGetBetter"/>
</Text>}
maxItemPerRow={5}
songs={Array.of(1, 2, 3, 4, 5).map((i) => ({
albumCover: "",
songTitle: 'Song ' + i,
artistName: "Artist",
songId: i
}))}
/>
<Row space={3} style={{ width: '100%', justifyContent: 'center' }}>
<Button backgroundColor='gray.300' onPress={() => navigation.navigate('Home')}>
<Translate translationKey='backBtn'/>
</Button>
<Button onPress={() => navigation.navigate('Song', { songId: 1 })}>
<Translate translationKey='playAgain'/>
</Button>
</Row>
</Column>
}
export default ScoreView;
+4 -3
View File
@@ -1,4 +1,4 @@
import { useRoute } from "@react-navigation/native";
import { useNavigation, useRoute } from "@react-navigation/native";
import { Button, Divider, Box, Center, Image, Text, VStack, PresenceTransition, Icon } from "native-base";
import { useQuery } from 'react-query';
import LoadingComponent from "../components/Loading";
@@ -15,6 +15,7 @@ interface SongLobbyProps {
const SongLobbyView = () => {
const route = useRoute();
const navigation = useNavigation();
const props: SongLobbyProps = route.params as any;
const songQuery = useQuery(['song', props.songId], () => API.getSong(props.songId));
const chaptersQuery = useQuery(['song', props.songId, 'chapters'], () => API.getSongChapters(props.songId));
@@ -41,10 +42,10 @@ const SongLobbyView = () => {
<Text bold fontSize='lg'>{songQuery.data!.title}</Text>
<Text>
<Translate translationKey='level'
format={(level) => `3:20 - ${level} - ${ chaptersQuery.data!.reduce((a, b) => a + b.difficulty, 0) / chaptersQuery.data!.length }`}
format={(level) => `${level} - ${ chaptersQuery.data!.reduce((a, b) => a + b.difficulty, 0) / chaptersQuery.data!.length }`}
/>
</Text>
<Button width='auto' rightIcon={<Icon as={Ionicons} name="play-outline"/>}>
<Button width='auto' onPress={() => navigation.navigate('Play', { songId: songQuery.data?.id })} rightIcon={<Icon as={Ionicons} name="play-outline"/>}>
<Translate translationKey='playBtn'/>
</Button>
</Box>
+817 -870
View File
File diff suppressed because it is too large Load Diff