68 Commits

Author SHA1 Message Date
Clément Le Bihan
74f804bb9a Myatom 2024-01-18 10:59:48 +01:00
Clément Le Bihan
0b78772d0b Fix canva crash on mobile 2024-01-18 10:59:48 +01:00
Clément Le Bihan
9e3c2d1cca added playType correct check 2024-01-18 10:59:48 +01:00
Clément Le Bihan
1b097163a4 removed div 2024-01-18 10:59:48 +01:00
GitBluub
c61d17baa7 fix ? 2024-01-18 10:59:48 +01:00
GitBluub
be8867e12f scoro wrongs msgs 2024-01-18 05:30:21 +01:00
GitBluub
00f98151c1 list compreeeheeension 2024-01-18 04:04:55 +01:00
GitBluub
5d78d8b5dd use cursor to create partition 2024-01-18 04:04:55 +01:00
GitBluub
0bd12bbf34 pretty 2024-01-18 01:21:03 +01:00
GitBluub
88cb7b2b65 is working i swear 2024-01-18 01:21:03 +01:00
GitBluub
69329118f7 aaaaaa 2024-01-18 01:21:03 +01:00
GitBluub
2781276c12 fix prettier 2024-01-18 01:21:03 +01:00
GitBluub
a24a960184 fix practice mode no sound and score system during practice 2024-01-18 01:21:03 +01:00
GitBluub
9fd70d3110 fix normal scoro mode 2024-01-18 01:21:03 +01:00
GitBluub
c1d714e02a practice mode working, timestamp is delayed for some reason 2024-01-18 01:21:03 +01:00
GitBluub
c08a1a2c74 front practice basically working, need cleanup 2024-01-18 01:21:03 +01:00
GitBluub
23a1ff8d19 front practice listener and scorometer sending timestamp 2024-01-18 01:21:03 +01:00
GitBluub
b80167001f practice scoro rework 2024-01-18 01:21:03 +01:00
Clément Le Bihan
8c2a53aa41 Added profile view mobile 2024-01-16 16:20:56 +01:00
Clément Le Bihan
dcca780f2d pretty fix 2024-01-15 02:14:43 +01:00
Clément Le Bihan
9150817c05 Removed custom keyextractor of flatlist in musicList due to weird behaviours and still using special icon button for web 2024-01-15 02:14:43 +01:00
Clément Le Bihan
d57606dd53 Fixed flatlist keyextractor 2024-01-15 02:14:43 +01:00
Clément Le Bihan
52f2c94fb7 We can now see MusicItem IconButtons on Android (Play & Likes) 2024-01-15 02:14:43 +01:00
Clément Le Bihan
1952625098 Search results are now visible on android 2024-01-15 02:14:43 +01:00
Clément Le Bihan
10dbfda8a4 Fix search input bar wasn't visible on android 2024-01-15 02:14:43 +01:00
Clément Le Bihan
234335cf61 TextFormField now display error message without part of it being cut 2024-01-15 02:14:43 +01:00
Clément Le Bihan
52d40b43f0 Added mail in the ProfileView 2024-01-15 02:14:43 +01:00
Clément Le Bihan
50522bbe63 Fixed missing transflations in ScaffoldAuth and fontSize issues in Leaderboard view 2024-01-15 02:14:43 +01:00
Clément Le Bihan
ce927ea1a4 Added missing padding seen mostly on mobile and fixed some font rendering 2024-01-15 02:14:43 +01:00
Clément Le Bihan
aebf409cea Fixed dark glassmorphism theme on mobile 2024-01-15 02:14:43 +01:00
Clément Le Bihan
5f0ea41c04 Merge pull request #358 from Chroma-Case/feat/adc/search-view-v2
Feat/adc/search view v2
2024-01-14 18:26:32 +01:00
Clément Le Bihan
d3c7e4a0a1 Merge branch 'main' into feat/adc/search-view-v2 2024-01-14 17:55:07 +01:00
Amaury Danis Cousandier
a3893bdb2b yay 2024-01-14 16:21:26 +01:00
Amaury Danis Cousandier
4ba4303b1e fix(../V2/SearchView): actual music list used + minor fixes 2024-01-14 16:13:14 +01:00
e779876f54 Fix id parsing 2024-01-14 14:37:30 +01:00
bd9edaa60e Oups 2024-01-14 14:17:59 +01:00
Amaury Danis Cousandier
f2ad34c8ab feat(search view v2): update API.searchSongs 2024-01-13 10:00:59 +01:00
Amaury Danis Cousandier
131d7bf688 Merge branch 'main' into feat/adc/search-view-v2 2024-01-13 08:32:32 +01:00
Amaury Danis Cousandier
38110d2840 merge dev NOT READY TO MERGE INTO MAIN 2024-01-12 19:28:20 +01:00
Amaury Danis Cousandier
fd60f2d171 artist and genre keys to refetch without changing the query 2024-01-12 17:56:42 +01:00
Amaury Danis Cousandier
86b2c1be50 histo 2024-01-12 17:53:39 +01:00
Amaury Danis Cousandier
627b8df658 css fixed 2024-01-12 16:54:54 +01:00
Amaury Danis Cousandier
3f0d0d523b like state 2024-01-12 09:19:06 +01:00
Amaury Danis Cousandier
29a9ffce74 pretty tsc lint 2024-01-11 17:56:32 +01:00
Amaury Danis Cousandier
a69e5ac009 fix artist name 2024-01-11 17:54:50 +01:00
Amaury Danis Cousandier
caa3322676 liked handled properly 2024-01-11 17:46:27 +01:00
Amaury Danis Cousandier
934010a0c1 tsc pretty lint 2024-01-09 21:35:48 +01:00
Amaury Danis Cousandier
29b2bedae0 wip 2024-01-09 21:28:37 +01:00
Amaury Danis Cousandier
7a2b877714 feat(searchview2): wip 2024-01-08 23:31:15 +01:00
Amaury
9416393618 Update SearchView.tsx var name 2024-01-08 12:24:38 +01:00
Amaury
eb245118dc style(../V2/SearchView): function name change 2024-01-08 12:17:48 +01:00
danis
40f16ab9ca lint 2024-01-08 00:00:40 +01:00
danis
a33d56bd61 fix(searchview2): fix types and remove deprecated search components 2024-01-07 23:54:35 +01:00
danis
c7c9250594 Merge branch 'main' into feat/adc/search-view-v2 2024-01-07 23:43:32 +01:00
danis
1b1659fe92 fix(../V2/SearchView): key 2024-01-07 23:34:52 +01:00
danis
3c9d71a757 feat(../V2/SearchView): artist name retrieval 2024-01-07 23:26:03 +01:00
danis
342099157e feat(../V2/SearchView): wip 2024-01-07 23:17:20 +01:00
danis
bb7a17fc22 feat(../V2/SearchView: copied working parts of music list 2024-01-07 17:22:02 +01:00
danis
0ea8cb86bb fix(searchViewV2): the fat of the land 2024-01-06 15:17:18 +01:00
danis
90f9574a6f merge main 2024-01-06 10:53:59 +01:00
danis
f2f7ec3f8d fixed a thing or two 2024-01-06 10:50:57 +01:00
danis
88b111529b Merge branch 'main' into feat/adc/search-view-v2 2024-01-04 21:02:40 +01:00
danis
5fc937d81b wip 2023-12-22 11:37:15 +01:00
danis
b3853646cb t1 2023-12-21 17:40:23 +01:00
danis
dac9849ef5 bug fix 2023-12-07 20:17:18 +01:00
danis
11ed8f90fd stupid hooks rules 2023-12-07 20:08:21 +01:00
danis
5d103c6687 feat(search): proper data passing through handler 2023-12-07 17:18:00 +01:00
danis
be926dcaed feat(search): exchange between search bar and searchView. paella 2023-12-07 16:48:25 +01:00
33 changed files with 11261 additions and 904 deletions

View File

@@ -1,3 +1,3 @@
FROM node:17
FROM node:18.10.0
WORKDIR /app
CMD npm i ; npx prisma generate ; npx prisma migrate dev ; npm run start:dev

10761
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Put } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { ScoresService } from "./scores.service";
import { User } from "@prisma/client";
@@ -13,4 +13,10 @@ export class ScoresController {
getTopTwenty(): Promise<User[]> {
return this.scoresService.topTwenty();
}
// @ApiOkResponse{{description: "Successfully updated the user's total score"}}
// @Put("/add")
// addScore(): Promise<void> {
// return this.ScoresService.add()
// }
}

View File

@@ -28,15 +28,15 @@ import { ArtistController } from "src/artist/artist.controller";
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get("songs/:query")
@Get("songs")
@ApiOkResponse({ type: _Song, isArray: true })
@ApiOperation({ description: "Search a song" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async searchSong(
@Request() req: any,
@Query("q") query: string | null,
@Query("artistId") artistId: number,
@Query("genreId") genreId: number,
@Query("artistId", new ParseIntPipe({ optional: true })) artistId: number,
@Query("genreId", new ParseIntPipe({ optional: true })) genreId: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
@@ -51,7 +51,7 @@ export class SearchController {
);
}
@Get("artists/:query")
@Get("artists")
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _Artist, isArray: true })
@ApiUnauthorizedResponse({ description: "Invalid token" })

View File

@@ -5,7 +5,6 @@ volumes:
scoro_logs:
meilisearch:
services:
back:
#platform: linux/amd64
@@ -22,7 +21,7 @@ services:
depends_on:
db:
condition: service_healthy
meilisearch:
condition: service_healthy
env_file:
@@ -93,15 +92,14 @@ services:
- "4567:4567"
meilisearch:
image: getmeili/meilisearch:v1.4
image: getmeili/meilisearch:v1.5
volumes:
- meilisearch:/meili_data
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 10s
timeout: 10s
retries: 5

View File

@@ -1,5 +1,4 @@
import Artist, { ArtistHandler } from './models/Artist';
import Album from './models/Album';
import Chapter from './models/Chapter';
import Lesson from './models/Lesson';
import Genre, { GenreHandler } from './models/Genre';
@@ -24,6 +23,7 @@ import * as yup from 'yup';
import { base64ToBlob } from './utils/base64ToBlob';
import { ImagePickerAsset } from 'expo-image-picker';
import { SongCursorInfos, SongCursorInfosHandler } from './models/SongCursorInfos';
import { searchProps } from './views/V2/SearchView';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -497,84 +497,6 @@ export default class API {
};
}
/**
* Search a song by its name
* @param query the string used to find the songs
*/
public static searchSongs(query: string): Query<Song[]> {
return {
key: ['search', 'song', query],
exec: () =>
API.fetch(
{
route: `/search/songs/${query}`,
},
{ handler: ListHandler(SongHandler) }
),
};
}
/**
* Search artists by name
* @param query the string used to find the artists
*/
public static searchArtists(query: string): Query<Artist[]> {
return {
key: ['search', 'artist', query],
exec: () =>
API.fetch(
{
route: `/search/artists/${query}`,
},
{ handler: ListHandler(ArtistHandler) }
),
};
}
/**
* Search Album by name
* @param query the string used to find the album
*/
public static searchAlbum(query: string): Query<Album[]> {
return {
key: ['search', 'album', query],
exec: async () => [
{
id: 1,
name: 'Super Trooper',
},
{
id: 2,
name: 'Kingdom Heart 365/2 OST',
},
{
id: 3,
name: 'The Legend Of Zelda Ocarina Of Time OST',
},
{
id: 4,
name: 'Random Access Memories',
},
],
};
}
/**
* Retrieve music genres
*/
public static searchGenres(query: string): Query<Genre[]> {
return {
key: ['search', 'genre', query],
exec: () =>
API.fetch(
{
route: `/search/genres/${query}`,
},
{ handler: ListHandler(GenreHandler) }
),
};
}
/**
* Retrieve a lesson
* @param lessonId the id to find the lesson
@@ -780,6 +702,29 @@ export default class API {
return `${API.baseUrl}/song/${songId}/assets/partition`;
}
public static searchSongs(query: searchProps, include?: SongInclude[]): Query<Song[]> {
const queryParams: string[] = [];
if (query.query) queryParams.push(`q=${encodeURIComponent(query.query)}`);
if (query.artist) queryParams.push(`artistId=${query.artist}`);
if (query.genre) queryParams.push(`genreId=${query.genre}`);
if (include) queryParams.push(`include=${include.join(',')}`);
const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
return {
key: ['search', query.query, query.artist, query.genre, include],
exec: () => {
return API.fetch(
{
route: `/search/songs${queryString}`,
},
{ handler: ListHandler(SongHandler) }
);
},
};
}
public static getPartitionMelodyUrl(songId: number): string {
return `${API.baseUrl}/song/${songId}/assets/melody`;
}

View File

@@ -30,9 +30,8 @@ const phoneLightGlassmorphism = {
900: 'rgb(248, 250, 254)',
1000: 'rgb(252, 254, 254)',
};
const lightGlassmorphism =
Platform.OS === 'web' ? defaultLightGlassmorphism : phoneLightGlassmorphism;
const darkGlassmorphism = {
const defaultDarkGlassmorphism = {
50: 'rgba(16,16,20,0.9)',
100: 'rgba(16,16,20,0.1)',
200: 'rgba(16,16,20,0.2)',
@@ -46,6 +45,24 @@ const darkGlassmorphism = {
1000: 'rgba(16,16,20,1)',
};
const phoneDarkGlassmorphism = {
50: 'rgb(10, 14, 38)',
100: 'rgb(14, 18, 42)',
200: 'rgb(18, 22, 46)',
300: 'rgb(22, 26, 50)',
400: 'rgb(26, 30, 54)',
500: 'rgb(10, 20, 54)',
600: 'rgb(14, 24, 58)',
700: 'rgb(18, 28, 62)',
800: 'rgb(22, 32, 66)',
900: 'rgb(26, 36, 70)',
1000: 'rgb(30, 40, 74)',
};
const lightGlassmorphism =
Platform.OS === 'web' ? defaultLightGlassmorphism : phoneLightGlassmorphism;
const darkGlassmorphism = Platform.OS === 'web' ? defaultDarkGlassmorphism : phoneDarkGlassmorphism;
const ThemeProvider = ({ children }: { children: JSX.Element }) => {
const colorScheme = useColorScheme();

View File

@@ -2,7 +2,12 @@ import * as React from 'react';
import { Platform, View } from 'react-native';
import API from '../../API';
import { useQuery } from '../../Queries';
import Animated, { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
import Animated, {
useSharedValue,
withTiming,
Easing,
useAnimatedStyle,
} from 'react-native-reanimated';
import { CursorInfoItem } from '../../models/SongCursorInfos';
import { Audio } from 'expo-av';
import { SvgContainer } from './SvgContainer';
@@ -10,7 +15,8 @@ import LoadingComponent from '../Loading';
import { SplendidGrandPiano } from 'smplr';
export type ParitionMagicProps = {
timestamp: number;
playType: 'practice' | 'normal' | null;
timestamp: React.MutableRefObject<number>;
songID: number;
shouldPlay: boolean;
onEndReached: () => void;
@@ -34,16 +40,18 @@ const getCursorToPlay = (
return;
}
for (let i = cursorInfos.length - 1; i > currentCurIdx; i--) {
const cursorInfo = cursorInfos[i]!;
if (cursorInfo.timestamp <= timestamp) {
onCursorMove(cursorInfo, i);
if (cursorInfos[i]!.timestamp <= timestamp) {
return onCursorMove(cursorInfos[i]!, i);
}
}
};
const transitionDuration = 50;
export let updateCursor: (() => void) | undefined = undefined;
const PartitionMagic = ({
playType,
timestamp,
songID,
shouldPlay,
@@ -72,6 +80,37 @@ const PartitionMagic = ({
const cursorTop = (data?.cursors[cursorDisplayIdx]?.y ?? 0) - cursorPaddingVertical;
const cursorLeft = (data?.cursors[0]?.x ?? 0) - cursorPaddingHorizontal;
updateCursor = () => {
if (!shouldPlay) return;
if (!data || data?.cursors.length === 0) return;
getCursorToPlay(
data!.cursors,
currentCurIdx.current,
timestamp.current + transitionDuration,
(cursor, idx) => {
currentCurIdx.current = idx;
partitionOffset.value = withTiming(
-(cursor.x - data!.cursors[0]!.x) / partitionDims[0],
{
duration: transitionDuration,
easing: Easing.inOut(Easing.ease),
}
);
if (idx === data!.cursors.length - 1) {
setEndPartitionReached(true);
}
if (playType === 'practice') return;
if (!isPianoLoaded) return;
cursor.notes.forEach((note) => {
piano.current?.start({
note: note.note,
duration: note.duration,
});
});
}
);
};
if (!endPartitionReached && currentCurIdx.current + 1 === data?.cursors.length) {
// weird contraption but the mobile don't want classic functions to be called
// with the withTiming function :(
@@ -79,6 +118,11 @@ const PartitionMagic = ({
}
React.useEffect(() => {
// In practice mode, no sound is played so just act as the piano is loaded
if (playType === 'practice' || !playType) {
setIsPianoLoaded(true);
return;
}
if (Platform.OS === 'web' && !piano.current) {
const audio = new AudioContext();
piano.current = new SplendidGrandPiano(audio);
@@ -109,7 +153,7 @@ const PartitionMagic = ({
piano.current = null;
}
};
}, []);
}, [playType]);
const partitionDims = React.useMemo<[number, number]>(() => {
return [data?.pageWidth ?? 0, data?.pageHeight ?? 1];
}, [data]);
@@ -146,65 +190,18 @@ const PartitionMagic = ({
}, [shouldPlay]);
React.useEffect(() => {
if (endPartitionReached) {
if (endPartitionReached && playType === 'normal') {
// if the audio is unsync
melodySound.current?.pauseAsync();
onEndReached();
}
}, [endPartitionReached]);
React.useEffect(() => {
if (!melodySound.current || !melodySound.current._loaded) return;
if (!data || data?.cursors.length === 0) return;
melodySound.current.setOnPlaybackStatusUpdate((status) => {
//@ts-expect-error positionMillis is not in the type
const timestamp = status?.positionMillis ?? 0;
getCursorToPlay(
data!.cursors,
currentCurIdx.current,
timestamp + transitionDuration,
(cursor, idx) => {
currentCurIdx.current = idx;
partitionOffset.value = withTiming(
-(cursor.x - data!.cursors[0]!.x) / partitionDims[0],
{
duration: transitionDuration,
easing: Easing.inOut(Easing.ease),
}
);
}
);
});
}, [data?.cursors, melodySound.current?._loaded]);
React.useEffect(() => {
if (!shouldPlay) return;
if (!piano.current || !isPianoLoaded) return;
if (!data || data?.cursors.length === 0) return;
getCursorToPlay(
data!.cursors,
currentCurIdx.current,
timestamp + transitionDuration,
(cursor, idx) => {
currentCurIdx.current = idx;
partitionOffset.value = withTiming(
-(cursor.x - data!.cursors[0]!.x) / partitionDims[0],
{
duration: transitionDuration,
easing: Easing.inOut(Easing.ease),
}
);
cursor.notes.forEach((note) => {
piano.current?.start({
note: note.note,
duration: note.duration,
});
});
}
);
}, [timestamp, data?.cursors, isPianoLoaded]);
React.useEffect(updateCursor, [data?.cursors, isPianoLoaded, timestamp.current]);
const animatedStyle = useAnimatedStyle(() => ({
left: `${partitionOffset.value * 100}%`,
}));
return (
<View
style={{
@@ -238,15 +235,17 @@ const PartitionMagic = ({
}}
>
<Animated.View
style={{
position: 'absolute',
height: '100%',
aspectRatio: partitionDims[0] / partitionDims[1],
left: `${partitionOffset.value * 100}%`,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
style={[
animatedStyle,
{
position: 'absolute',
height: '100%',
aspectRatio: partitionDims[0] / partitionDims[1],
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
},
]}
>
<SvgContainer
url={getSVGURL(songID)}

View File

@@ -0,0 +1,35 @@
import { Text, useTheme } from 'native-base';
import { MutableRefObject, useEffect, useState } from 'react';
export let updateTime: (() => void) | undefined = undefined;
type PlayTimestampShowProps = {
paused: boolean;
time: MutableRefObject<number>;
};
export const PlayTimestampShow = ({ paused, time }: PlayTimestampShowProps) => {
const { colors } = useTheme();
const textColor = colors.text;
const [timeD, setTimeD] = useState<number>(time.current);
updateTime = () => {
setTimeD(time.current);
};
useEffect(updateTime, [time]);
return (
<Text color={textColor[900]}>
{timeD < 0
? paused
? '0:00'
: Math.floor((timeD % 60000) / 1000)
.toFixed(0)
.toString()
: `${Math.floor(timeD / 60000)}:${Math.floor((timeD % 60000) / 1000)
.toFixed(0)
.toString()
.padStart(2, '0')}`}
</Text>
);
};

View File

@@ -6,10 +6,12 @@ import { MetronomeControls } from '../Metronome';
import StarProgress from '../StarProgress';
import Song from '../../models/Song';
import { useTheme } from 'native-base';
import { PlayTimestampShow } from './PlayTimestampShow';
type PlayViewControlBarProps = {
playType: 'practice' | 'normal' | null;
song: Song;
time: number;
time: React.MutableRefObject<number>;
paused: boolean;
score: number;
disabled: boolean;
@@ -19,6 +21,7 @@ type PlayViewControlBarProps = {
};
const PlayViewControlBar = ({
playType,
song,
time,
paused,
@@ -107,17 +110,19 @@ const PlayViewControlBar = ({
gap: isPhone ? 10 : 25,
}}
>
<IconButton
size="sm"
variant="solid"
disabled={disabled}
_icon={{
as: Ionicons,
color: colors.coolGray[900],
name: paused ? 'play' : 'pause',
}}
onPress={paused ? onResume : onPause}
/>
{playType != 'practice' && (
<IconButton
size="sm"
variant="solid"
disabled={disabled}
_icon={{
as: Ionicons,
color: colors.coolGray[900],
name: paused ? 'play' : 'pause',
}}
onPress={paused ? onResume : onPause}
/>
)}
<IconButton
size="sm"
colorScheme="coolGray"
@@ -128,18 +133,7 @@ const PlayViewControlBar = ({
}}
onPress={onEnd}
/>
<Text color={textColor[900]}>
{time < 0
? paused
? '0:00'
: Math.floor((time % 60000) / 1000)
.toFixed(0)
.toString()
: `${Math.floor(time / 60000)}:${Math.floor((time % 60000) / 1000)
.toFixed(0)
.toString()
.padStart(2, '0')}`}
</Text>
<PlayTimestampShow paused={paused} time={time} />
<StarProgress
value={score}
max={100}
@@ -162,7 +156,7 @@ const PlayViewControlBar = ({
minWidth: 120,
}}
>
<MetronomeControls paused={paused} bpm={bpm.current} />
{playType != 'practice' && <MetronomeControls paused={paused} bpm={bpm.current} />}
</View>
</Row>
);

View File

@@ -121,7 +121,7 @@ const Graph = ({ songId, since }: GraphProps) => {
const ScoreGraph = () => {
const layout = useWindowDimensions();
const songs = useQuery(API.getAllSongs);
const songs = useQuery(API.getAllSongs());
const rangeOptions = [
{ label: '3 derniers jours', value: '3days' },
{ label: 'Dernière semaine', value: 'week' },

View File

@@ -46,7 +46,7 @@ const ScoreModal = (props: ScoreModalProps) => {
/>
))}
</Row>
<Text fontSize="3xl">{Math.max(score, 0)}%</Text>
<Text fontSize="3xl">{Math.max(score, 0).toFixed(2)}%</Text>
<Row w="100%" style={{ justifyContent: 'space-between' }}>
<Translate translationKey="precision" />
<Text>{props.precision}%</Text>

View File

@@ -1,119 +0,0 @@
import { Icon, Input, Button, Flex } from 'native-base';
import React from 'react';
import { MaterialIcons } from '@expo/vector-icons';
import { translate } from '../i18n/i18n';
import { SearchContext } from '../views/SearchView';
import { debounce } from 'lodash';
export type Filter = 'artist' | 'song' | 'genre' | 'all' | 'favorites';
type FilterButton = {
name: string;
callback: () => void;
id: Filter;
};
const SearchBar = () => {
const { filter, updateFilter } = React.useContext(SearchContext);
const { stringQuery, updateStringQuery } = React.useContext(SearchContext);
const [barText, updateBarText] = React.useState(stringQuery);
const debouncedUpdateStringQuery = debounce(updateStringQuery, 500);
// there's a bug due to recursive feedback that erase the text as soon as you type this is a temporary "fix"
// will probably be fixed by removing the React.useContext
// React.useEffect(() => {
// updateBarText(stringQuery);
// }, [stringQuery]);
const handleClearQuery = () => {
updateStringQuery('');
updateBarText('');
};
const handleChangeText = (text: string) => {
debouncedUpdateStringQuery(text);
updateBarText(text);
};
const filters: FilterButton[] = [
{
name: translate('allFilter'),
callback: () => updateFilter('all'),
id: 'all',
},
{
name: translate('favoriteFilter'),
callback: () => updateFilter('favorites'),
id: 'favorites',
},
{
name: translate('artistFilter'),
callback: () => updateFilter('artist'),
id: 'artist',
},
{
name: translate('songsFilter'),
callback: () => updateFilter('song'),
id: 'song',
},
{
name: translate('genreFilter'),
callback: () => updateFilter('genre'),
id: 'genre',
},
];
return (
<Flex m={3} flexDirection={['column', 'row']}>
<Input
onChangeText={(text) => handleChangeText(text)}
variant={'rounded'}
value={barText}
rounded={'full'}
placeholder={translate('search')}
width={['100%', '50%']} //responsive array syntax with native-base
py={2}
px={2}
fontSize={'12'}
InputLeftElement={
<Icon
m={[1, 2]}
ml={[2, 3]}
size={['4', '6']}
color="gray.400"
as={<MaterialIcons name="search" />}
/>
}
InputRightElement={
<Icon
m={[1, 2]}
mr={[2, 3]}
size={['4', '6']}
color="gray.400"
onPress={handleClearQuery}
as={<MaterialIcons name="close" />}
/>
}
/>
<Flex flexDirection={'row'}>
{filters.map((btn) => (
<Button
key={btn.name}
rounded={'full'}
onPress={btn.callback}
mx={[2, 5]}
my={[1, 0]}
minW={[30, 20]}
variant={filter === btn.id ? 'solid' : 'outline'}
>
{btn.name}
</Button>
))}
</Flex>
</Flex>
);
};
export default SearchBar;

View File

@@ -1,277 +0,0 @@
import React from 'react';
import {
VStack,
Heading,
Box,
Card,
Flex,
useBreakpointValue,
Column,
ScrollView,
} from 'native-base';
import { SafeAreaView } from 'react-native';
import { SearchContext } from '../views/SearchView';
import { useQuery } from '../Queries';
import { Translate, translate } from '../i18n/i18n';
import API from '../API';
import LoadingComponent, { LoadingView } from './Loading';
import ArtistCard from './ArtistCard';
import GenreCard from './GenreCard';
import SongCard from './SongCard';
import CardGridCustom from './CardGridCustom';
import SearchHistoryCard from './HistoryCard';
import Song from '../models/Song';
import { useNavigation } from '../Navigation';
import SongRow from '../components/SongRow';
import FavSongRow from './FavSongRow';
import { useLikeSongMutation } from '../utils/likeSongMutation';
const swaToSongCardProps = (song: Song) => ({
songId: song.id,
name: song.name,
artistName: song.artist!.name,
cover: song.cover,
});
const HomeSearchComponent = () => {
const { updateStringQuery } = React.useContext(SearchContext);
const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery(
API.getSearchHistory(0, 12),
{ enabled: true }
);
const songSuggestions = useQuery(API.getSongSuggestions(['artist']));
return (
<VStack mt="5" style={{ overflow: 'hidden' }}>
<Card shadow={3} mb={5}>
<Heading margin={5}>{translate('lastSearched')}</Heading>
{isLoadingHistory ? (
<LoadingComponent />
) : (
<CardGridCustom
content={historyData.map((h) => {
return {
...h,
timestamp: h.timestamp.toLocaleString(),
onPress: () => {
updateStringQuery(h.query);
},
};
})}
cardComponent={SearchHistoryCard}
/>
)}
</Card>
<Card shadow={3} mt={5} mb={5}>
<Heading margin={5}>{translate('songsToGetBetter')}</Heading>
{!songSuggestions.data ? (
<LoadingComponent />
) : (
<CardGridCustom
content={songSuggestions.data.map(swaToSongCardProps)}
cardComponent={SongCard}
/>
)}
</Card>
</VStack>
);
};
type SongsSearchComponentProps = {
maxRows?: number;
};
const SongsSearchComponent = (props: SongsSearchComponentProps) => {
const navigation = useNavigation();
const { songData } = React.useContext(SearchContext);
const favoritesQuery = useQuery(API.getLikedSongs(['artist']));
const { mutate } = useLikeSongMutation();
return (
<ScrollView>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{songData?.length ? (
songData.slice(0, props.maxRows).map((comp, index) => (
<SongRow
key={index}
song={comp}
isLiked={
!favoritesQuery.data?.find((query) => query?.songId == comp.id)
}
handleLike={async (state: boolean, songId: number) =>
mutate({ songId: songId, like: state })
}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');
navigation.navigate('Play', { songId: comp.id });
}}
/>
))
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
</ScrollView>
);
};
type ItemSearchComponentProps = {
maxItems?: number;
};
const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
const { artistData } = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Translate translationKey="artistFilter" fontSize="xl" fontWeight="bold" mt={4} />
{artistData?.length ? (
<CardGridCustom
content={artistData
.slice(0, props.maxItems ?? artistData.length)
.map((artistData) => ({
image: API.getArtistIllustration(artistData.id),
name: artistData.name,
id: artistData.id,
onPress: () => {
API.createSearchHistoryEntry(artistData.name, 'artist');
navigation.navigate('Artist', { artistId: artistData.id });
},
}))}
cardComponent={ArtistCard}
/>
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
);
};
const GenreSearchComponent = (props: ItemSearchComponentProps) => {
const { genreData } = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Translate translationKey="genreFilter" fontSize="xl" fontWeight="bold" mt={4} />
{genreData?.length ? (
<CardGridCustom
content={genreData.slice(0, props.maxItems ?? genreData.length).map((g) => ({
image: API.getGenreIllustration(g.id),
name: g.name,
id: g.id,
onPress: () => {
API.createSearchHistoryEntry(g.name, 'genre');
navigation.navigate('Genre', { genreId: g.id });
},
}))}
cardComponent={GenreCard}
/>
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
);
};
const FavoritesComponent = () => {
const navigation = useNavigation();
const favoritesQuery = useQuery(API.getLikedSongs());
if (favoritesQuery.isError) {
navigation.navigate('Error');
return <></>;
}
if (!favoritesQuery.data) {
return <LoadingView />;
}
return (
<ScrollView>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{favoritesQuery.data?.map((songData) => (
<FavSongRow
key={songData.id}
song={songData.song}
addedDate={songData.addedDate}
onPress={() => {
API.createSearchHistoryEntry(songData.song.name, 'song'); //todo
navigation.navigate('Play', { songId: songData.song!.id });
}}
/>
))}
</Box>
</ScrollView>
);
};
const AllComponent = () => {
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
return (
<SafeAreaView>
<Flex
flexWrap="wrap"
direction={isMobileView ? 'column' : 'row'}
justifyContent={['flex-start']}
mt={4}
>
<Column w={isMobileView ? '100%' : '50%'}>
<Box minH={isMobileView ? 100 : 200}>
<ArtistSearchComponent maxItems={6} />
</Box>
<Box minH={isMobileView ? 100 : 200}>
<GenreSearchComponent maxItems={6} />
</Box>
</Column>
<Box w={isMobileView ? '100%' : '50%'}>
<SongsSearchComponent maxRows={9} />
</Box>
</Flex>
</SafeAreaView>
);
};
const FilterSwitch = () => {
const { filter } = React.useContext(SearchContext);
const [currentFilter, setCurrentFilter] = React.useState(filter);
React.useEffect(() => {
setCurrentFilter(filter);
}, [filter]);
switch (currentFilter) {
case 'all':
return <AllComponent />;
case 'song':
return <SongsSearchComponent />;
case 'artist':
return <ArtistSearchComponent />;
case 'genre':
return <GenreSearchComponent />;
case 'favorites':
return <FavoritesComponent />;
default:
return (
<Translate translationKey="unknownError" format={(e) => `${e}: ${currentFilter}`} />
);
}
};
export const SearchResultComponent = () => {
const { stringQuery } = React.useContext(SearchContext);
const { filter } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim() || filter == 'favorites';
return shouldOutput ? (
<Box p={5}>
<FilterSwitch />
</Box>
) : (
<HomeSearchComponent />
);
};

View File

@@ -1,9 +1,18 @@
/* eslint-disable react/prop-types */
import React, { useMemo, memo } from 'react';
import { StyleSheet, ViewStyle, Image } from 'react-native';
import { Column, HStack, Row, Stack, Text, useBreakpointValue, useTheme } from 'native-base';
import { StyleSheet, ViewStyle, Image, Platform } from 'react-native';
import {
Column,
HStack,
Row,
Stack,
Text,
useBreakpointValue,
useTheme,
IconButton,
} from 'native-base';
import { HeartAdd, HeartRemove, Play } from 'iconsax-react-native';
import IconButton from './IconButton';
import WebIconButton from './IconButton';
import Spacer from '../../components/UI/Spacer';
import { useTranslation } from 'react-i18next';
@@ -103,18 +112,15 @@ function MusicItemComponent(props: MusicItemType) {
backgroundColor: colors.coolGray[500],
paddingRight: screenSize === 'small' ? 8 : 16,
},
playButtonContainer: {
playButton: {
backgroundColor: colors.primary[300],
borderRadius: 999,
zIndex: 1,
position: 'absolute',
right: -8,
bottom: -6,
},
playButton: {
backgroundColor: colors.primary[300],
borderRadius: 999,
},
image: {
position: 'relative',
width: screenSize === 'xl' ? 80 : 60,
height: screenSize === 'xl' ? 80 : 60,
},
@@ -124,6 +130,10 @@ function MusicItemComponent(props: MusicItemType) {
},
songContainer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'center',
},
stats: {
display: 'flex',
@@ -144,35 +154,61 @@ function MusicItemComponent(props: MusicItemType) {
return (
<HStack space={screenSize === 'xl' ? 2 : 1} style={[styles.container, props.style]}>
<Stack style={{ position: 'relative', overflow: 'hidden' }}>
<IconButton
containerStyle={styles.playButtonContainer}
style={styles.playButton}
padding={8}
onPress={props.onPlay}
color="#FFF"
icon={Play}
variant="Bold"
size={24}
/>
<Image source={{ uri: props.image }} style={styles.image} />
<IconButton
style={styles.playButton}
onPress={props.onPlay}
icon={<Play size={24} variant="Bold" color="#FFF" />}
/>
</Stack>
<Column style={{ flex: 4, width: '100%', justifyContent: 'center' }}>
<Text numberOfLines={1} style={styles.artistText}>
<Text isTruncated numberOfLines={1} style={styles.artistText}>
{props.artist}
</Text>
{screenSize === 'xl' && <Spacer height="xs" />}
<Row space={2} style={styles.songContainer}>
<Text numberOfLines={1}>{props.song}</Text>
<IconButton
colorActive={colors.text[700]}
color={colors.primary[300]}
icon={HeartAdd}
iconActive={HeartRemove}
activeVariant="Bold"
size={screenSize === 'xl' ? 24 : 18}
isActive={props.liked}
onPress={props.onLike}
/>
<Row style={styles.songContainer}>
<Text
isTruncated
numberOfLines={1}
style={{
flexShrink: 1,
}}
>
{props.song}
</Text>
{Platform.OS === 'web' ? (
<WebIconButton
colorActive={colors.text[700]}
color={colors.primary[300]}
icon={HeartAdd}
iconActive={HeartRemove}
activeVariant="Bold"
size={screenSize === 'xl' ? 24 : 18}
isActive={props.liked}
onPress={props.onLike}
/>
) : (
<IconButton
variant={'unstyled'}
icon={
props.liked ? (
<HeartRemove
size={screenSize === 'xl' ? 24 : 18}
color={colors.primary[700]}
variant="Bold"
/>
) : (
<HeartAdd
size={screenSize === 'xl' ? 24 : 18}
color={colors.primary[300]}
/>
)
}
onPress={() => {
props.onLike(!props.liked);
}}
/>
)}
</Row>
</Column>
{[formattedLastScore, formattedBestScore].map((value, index) => (

View File

@@ -132,7 +132,9 @@ function MusicListCC({
style={{ marginBottom: 2 }}
/>
)}
keyExtractor={(item) => item.id.toString()}
keyExtractor={(item) => {
return `${item.id}`;
}}
ListFooterComponent={
hasMore ? (
<View style={styles.footerContainer}>
@@ -157,7 +159,7 @@ function MusicListCC({
// Styles for the MusicList component
const styles = StyleSheet.create({
container: {
flex: 1,
flexGrow: 1,
gap: 2,
borderRadius: 10,
},

View File

@@ -4,6 +4,7 @@ import { FunctionComponent } from 'react';
import { Linking, Platform, useWindowDimensions } from 'react-native';
import ButtonBase from './ButtonBase';
import { translate } from '../../i18n/i18n';
import { useTranslation } from 'react-i18next';
import API, { APIError } from '../../API';
import SeparatorBase from './SeparatorBase';
import LinkBase from './LinkBase';
@@ -38,6 +39,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
const dispatch = useDispatch();
const toast = useToast();
const colorScheme = useColorScheme();
const { t } = useTranslation();
const [logo] = useAssets(
colorScheme == 'light'
? require('../../assets/icon_light.png')
@@ -84,7 +86,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
)}
</Row>
<ButtonBase
title={translate('guestMode')}
title={t('guestMode')}
onPress={async () => {
try {
handleGuestLogin((accessToken: string) => {
@@ -152,7 +154,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
title={translate('continuewithgoogle')}
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
/>
<SeparatorBase>or</SeparatorBase>
<SeparatorBase>{t('authOrSection')}</SeparatorBase>
<Stack
space={3}
justifyContent="center"

View File

@@ -9,7 +9,7 @@ interface TextFormFieldProps extends TextFieldBaseProps {
error: string | null;
}
const ERROR_HEIGHT = 20;
const ERROR_HEIGHT = 25;
const ERROR_PADDING_TOP = 8;
const TextFormField: React.FC<TextFormFieldProps> = ({ error, style, ...textFieldBaseProps }) => {
@@ -49,7 +49,12 @@ const TextFormField: React.FC<TextFormFieldProps> = ({ error, style, ...textFiel
}}
>
<Warning2 size="16" color="#f7253d" variant="Bold" />
<Text isTruncated maxW={'100%'} style={styles.errorText}>
<Text
isTruncated
maxW={'100%'}
fontSize={styles.errorText.fontSize}
color={styles.errorText.color}
>
{error}
</Text>
</Animated.View>
@@ -62,14 +67,14 @@ const styles = StyleSheet.create({
width: '100%',
},
errorContainer: {
display: 'flex',
gap: 8,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 12,
},
errorText: {
color: '#f7253d',
fontSize: 12,
marginLeft: 8,
},
});

View File

@@ -6,8 +6,8 @@ import ButtonBase from '../UI/ButtonBase';
import { AddSquare, CloseCircle, SearchNormal1 } from 'iconsax-react-native';
import { useQuery } from '../../Queries';
import API from '../../API';
import Genre from '../../models/Genre';
import { translate } from '../../i18n/i18n';
import { searchProps } from '../../views/V2/SearchView';
type ArtistChipProps = {
name: string;
@@ -29,9 +29,9 @@ const ArtistChipComponent = (props: ArtistChipProps) => {
}}
>
{props.selected ? (
<CloseCircle size="32" color={'#ED4A51'} />
<CloseCircle size="24" color={'#ED4A51'} />
) : (
<AddSquare size="32" color={'#6075F9'} />
<AddSquare size="24" color={'#6075F9'} />
)}
<Text>{props.name}</Text>
</View>
@@ -40,15 +40,24 @@ const ArtistChipComponent = (props: ArtistChipProps) => {
);
};
const SearchBarComponent = () => {
const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => void }) => {
const [query, setQuery] = React.useState('');
const [genre, setGenre] = React.useState({} as Genre | undefined);
const [genre, setGenre] = React.useState('');
const [artist, setArtist] = React.useState('');
const artistsQuery = useQuery(API.getAllArtists());
const genresQuery = useQuery(API.getAllGenres());
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
const handleValidate = () => {
const searchData = {
query: query,
artist: artistsQuery.data?.find((a) => a.name === artist)?.id ?? undefined,
genre: genresQuery.data?.find((g) => g.name === genre)?.id ?? undefined,
};
props.onValidate(searchData);
};
return (
<View>
<View
@@ -57,62 +66,48 @@ const SearchBarComponent = () => {
borderBottomColor: '#9E9E9E',
display: 'flex',
flexDirection: isMobileView ? 'column' : 'row',
alignItems: 'center',
width: '100%',
margin: 5,
padding: 16,
padding: isMobileView ? 8 : 16,
gap: 10,
}}
>
<View
style={{
flexGrow: 0,
flexShrink: 0,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{artist && (
{!!artist && (
<View
style={{
flexGrow: 0,
flexShrink: 0,
flexDirection: 'row',
flexWrap: 'nowrap',
maxWidth: '100%',
}}
>
<ArtistChipComponent
onPress={() => setArtist('')}
name={artist}
selected={true}
/>
)}
</View>
</View>
)}
<View
style={{
flexGrow: 1,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexGrow: 1,
width: '100%',
}}
>
<View
style={{
flexGrow: 1,
flexShrink: 1,
}}
>
<View style={{ flexGrow: 1 }}>
<Input
type="text"
value={query}
variant={'unstyled'}
placeholder={translate('searchBarPlaceholder')}
style={{ width: '100%', height: 30 }}
style={{ height: 30, flex: 1 }}
onChangeText={(value) => setQuery(value)}
/>
</View>
<ButtonBase
type="menu"
icon={SearchNormal1}
style={{
flexShrink: 0,
flexGrow: 0,
}}
/>
<ButtonBase type="menu" icon={SearchNormal1} onPress={handleValidate} />
</View>
</View>
<View
@@ -146,6 +141,13 @@ const SearchBarComponent = () => {
key={index}
name={artist.name}
onPress={() => {
props.onValidate({
artist: artist.id,
genre:
genresQuery.data?.find((a) => a.name === genre)
?.id ?? undefined,
query: query,
});
setArtist(artist.name);
}}
/>
@@ -154,15 +156,20 @@ const SearchBarComponent = () => {
</ScrollView>
<View>
<Select
selectedValue={genre?.name}
selectedValue={genre}
placeholder={translate('genreFilter')}
accessibilityLabel="Genre"
onValueChange={(itemValue) => {
setGenre(
genresQuery.data?.find((genre) => {
genre.name == itemValue;
})
);
setGenre(itemValue);
props.onValidate({
artist:
artistsQuery.data?.find((a) => a.name === artist)?.id ??
undefined,
genre:
genresQuery.data?.find((g) => g.name === itemValue)?.id ??
undefined,
query: query,
});
}}
>
<Select.Item label={translate('emptySelection')} value="" />

View File

@@ -52,12 +52,10 @@ export const BoardRow = ({ userAvatarUrl, userPseudo, userLvl, index }: BoardRow
</View>
<Text
fontSize={16}
style={{
fontSize: 16,
fontStyle: 'normal',
flex: 1,
marginHorizontal: 10,
fontWeight: '500',
maxWidth: '100%',
}}
isTruncated
@@ -86,14 +84,7 @@ export const BoardRow = ({ userAvatarUrl, userPseudo, userLvl, index }: BoardRow
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 16,
fontStyle: 'normal',
fontWeight: '500',
textAlign: 'center',
}}
>
<Text fontSize={16} textAlign={'center'}>
{index}
</Text>
</View>

View File

@@ -62,15 +62,7 @@ export const PodiumCard = ({
</Avatar>
</View>
<View>
<Text
style={{
fontSize: 20,
fontWeight: '500',
maxWidth: '100%',
}}
numberOfLines={2}
isTruncated
>
<Text fontSize={20} numberOfLines={2} isTruncated>
{userPseudo ?? '---'}
</Text>
<View
@@ -82,12 +74,7 @@ export const PodiumCard = ({
gap: 5,
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '500',
}}
>
<Text fontSize={24} fontWeight={'bold'}>
{userLvl ?? '-'}
</Text>
<MedalStar size="24" variant="Bold" color={medalColor} />

View File

@@ -307,7 +307,7 @@ export const en = {
leaderBoardHeading: 'These are the best players',
leaderBoardHeadingFull:
'The players having the best scores, thanks to their exceptional accuracy, are highlighted here.',
emptySelection: 'None,',
emptySelection: 'None',
gamesPlayed: 'Games Played',
metronome: 'Metronome',
loading: 'Loading... Please Wait',
@@ -321,6 +321,8 @@ export const en = {
accountCreatedOn: 'Account Created on',
downloadAPKInstructions:
"Go to the latest release, unfold the 'Assets' section, and click 'android-build.apk'.",
discoverySuggestionSectionTitle: 'Suggested for you',
authOrSection: 'or',
};
export const fr: typeof en = {
@@ -643,10 +645,12 @@ export const fr: typeof en = {
whatIsChromacase: "Chromacase c'est quoi?",
clickHereForMoreInfo: "Cliquez ici pour plus d'info",
forgotPassword: "J'ai oublié mon mot de passe",
updateProfile: 'Changer le Profile',
updateProfile: 'Changer le Profil',
accountCreatedOn: 'Compte créé le',
downloadAPKInstructions:
"Télécharger 'android-build.apk' dans la section 'Assets' de la dernière release",
discoverySuggestionSectionTitle: 'Suggéré pour vous',
authOrSection: 'ou',
};
export const sp: typeof en = {
@@ -980,4 +984,6 @@ export const sp: typeof en = {
accountCreatedOn: 'Cuenta creada el',
downloadAPKInstructions:
"Descargue 'android-build.apk' en la sección 'Assets' de la última versión",
discoverySuggestionSectionTitle: 'Sugerido para ti',
authOrSection: 'o',
};

View File

@@ -18,11 +18,7 @@ const ForgotPasswordView = () => {
return 'Error with email, please contact support';
}
}
return (
<div>
<ForgotPasswordForm onSubmit={handleSubmit} />
</div>
);
return <ForgotPasswordForm onSubmit={handleSubmit} />;
};
export default ForgotPasswordView;

View File

@@ -29,22 +29,21 @@ const Leaderboardiew = () => {
] as const;
return (
<ScrollView>
<ScrollView
style={{
padding: 8,
}}
>
<Text
fontSize={20}
fontWeight={'500'}
style={{
fontSize: 20,
fontWeight: '500',
marginBottom: 16,
}}
>
{translate('leaderBoardHeading')}
</Text>
<Text
style={{
fontSize: 14,
fontWeight: '500',
}}
>
<Text fontSize={14} fontWeight={'500'}>
{translate('leaderBoardHeadingFull')}
</Text>
<View

View File

@@ -15,7 +15,7 @@ import { useStopwatch } from 'react-use-precision-timer';
import { MIDIAccess, MIDIMessageEvent, requestMIDIAccess } from '@motiz88/react-native-midi';
import * as Linking from 'expo-linking';
import url from 'url';
import PartitionMagic from '../components/Play/PartitionMagic';
import PartitionMagic, { updateCursor } from '../components/Play/PartitionMagic';
import useColorScheme from '../hooks/colorScheme';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme, useBreakpointValue } from 'native-base';
@@ -25,6 +25,7 @@ import { Clock, Cup } from 'iconsax-react-native';
import PlayViewControlBar from '../components/Play/PlayViewControlBar';
import ScoreModal from '../components/ScoreModal';
import { PlayScore, ScoreMessage } from '../components/Play/PlayScore';
import { updateTime as updateTimeControlBar } from '../components/Play/PlayTimestampShow';
type PlayViewProps = {
songId: number;
@@ -32,6 +33,7 @@ type PlayViewProps = {
// this a hot fix this should be reverted soon
let scoroBaseApiUrl = process.env.EXPO_PUBLIC_SCORO_URL!;
let interval: NodeJS.Timeout;
if (process.env.NODE_ENV != 'development' && Platform.OS === 'web') {
Linking.getInitialURL().then((initUrl) => {
@@ -67,10 +69,12 @@ const PlayView = ({ songId }: PlayViewProps) => {
const webSocket = useRef<WebSocket>();
const [paused, setPause] = useState<boolean>(true);
const stopwatch = useStopwatch();
const [time, setTime] = useState(0);
// const [time, setTime] = useState(0);
const time = useRef(0);
const [endResult, setEndResult] = useState<unknown>();
const [shouldPlay, setShouldPlay] = useState(false);
const songHistory = useQuery(API.getSongHistory(songId));
const endCalled = useRef(false);
const [score, setScore] = useState(0); // Between 0 and 100
const getElapsedTime = () => stopwatch.getElapsedRunningTime();
const [readyToPlay, setReadyToPlay] = useState(false);
@@ -112,6 +116,8 @@ const PlayView = ({ songId }: PlayViewProps) => {
};
const onEnd = () => {
if (endCalled.current == true) return;
endCalled.current = true;
stopwatch.stop();
if (webSocket.current?.readyState != WebSocket.OPEN) {
console.warn('onEnd: Websocket not open');
@@ -146,8 +152,16 @@ const PlayView = ({ songId }: PlayViewProps) => {
bearer: accessToken,
})
);
interval = setInterval(() => {
webSocket.current!.send(
JSON.stringify({
type: 'ping',
})
);
}, 15000);
};
webSocket.current.onclose = () => {
clearInterval(interval);
console.log('Websocket closed', endMsgReceived);
if (!endMsgReceived) {
toast.show({ description: 'Connection lost with Scorometer' });
@@ -165,19 +179,30 @@ const PlayView = ({ songId }: PlayViewProps) => {
toast.show({ description: 'Scoro: ' + data.error });
return;
}
if (data.type == 'pong') return;
if (data.type == 'end') {
const maxPoints = data.score.max_score || 1;
const points = data.overallScore;
endMsgReceived = true;
webSocket.current?.close();
setScore(Math.floor((Math.max(points, 0) * 100) / maxPoints));
setEndResult({ songId: song.data!.id, ...data });
return;
}
console.log(data);
const points = data.info.score;
const maxPoints = data.info.max_score || 1;
setScore(Math.floor((Math.max(points, 0) * 100) / maxPoints));
if (data.type == 'step') {
// setTime(data.timestamp);
time.current = data.timestamp;
updateCursor?.();
updateTimeControlBar?.();
return;
}
let formattedMessage = '';
let messageColor: ColorSchemeType | undefined;
@@ -218,8 +243,9 @@ const PlayView = ({ songId }: PlayViewProps) => {
};
inputs.forEach((input) => {
input.onmidimessage = (message) => {
const { command, note } = parseMidiMessage(message);
const keyIsPressed = command == 9;
const { command, note, velocity } = parseMidiMessage(message);
let keyIsPressed = command == 9;
if (velocity == 0) keyIsPressed = false;
webSocket.current?.send(
JSON.stringify({
@@ -237,15 +263,22 @@ const PlayView = ({ songId }: PlayViewProps) => {
};
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
if (playType == 'practice') return;
const interval = setInterval(() => {
setTime(() => getElapsedTime()); // Countdown
time.current = getElapsedTime();
updateCursor?.();
updateTimeControlBar?.();
}, 200);
return () => clearInterval(interval);
}, [playType]);
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
return () => {
ScreenOrientation.unlockAsync().catch(() => {});
stopwatch.stop();
clearInterval(interval);
};
}, []);
@@ -316,7 +349,10 @@ const PlayView = ({ songId }: PlayViewProps) => {
style={{}}
type="outlined"
title={translate('practiceBtn')}
onPress={async () => setPlayType('practice')}
onPress={async () => {
setPlayType('practice');
setShouldPlay(true);
}}
/>
<ButtonBase
style={{}}
@@ -386,6 +422,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
}}
>
<PartitionMagic
playType={playType}
shouldPlay={shouldPlay}
timestamp={time}
songID={song.data.id}
@@ -406,6 +443,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
/>
</View>
<PlayViewControlBar
playType={playType}
score={score}
time={time}
paused={paused}
@@ -416,7 +454,9 @@ const PlayView = ({ songId }: PlayViewProps) => {
setShouldPlay(false);
}}
onResume={() => {
setShouldPlay(true);
setTimeout(() => {
setShouldPlay(true);
}, 3000);
}}
/>
</SafeAreaView>

View File

@@ -34,7 +34,7 @@ const ProfileView = () => {
const isBigScreen = layout.width > 650;
return (
<Flex flex={1}>
<Flex flex={1} padding={8}>
<View
style={{
display: 'flex',
@@ -68,9 +68,10 @@ const ProfileView = () => {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 5,
}}
>
<Text fontSize={'xl'} style={{ paddingRight: 'auto' }}>
<Text fontSize={'xl'} isTruncated numberOfLines={2} flexShrink={1}>
{userQuery.data.name}
</Text>
<ButtonBase
@@ -80,10 +81,15 @@ const ProfileView = () => {
/>
</View>
<Translate
style={{ paddingBottom: 10, fontWeight: 'bold' }}
style={{ fontWeight: 'bold' }}
translationKey="accountCreatedOn"
format={(e) => `${e} ${userQuery.data.data.createdAt.toLocaleDateString()}`}
/>
<Translate
style={{ fontWeight: 'bold', paddingBottom: 10 }}
translationKey="email"
format={(e) => `${e} ${userQuery.data.email}`}
/>
<Flex
style={{
flexDirection: 'row',

View File

@@ -1,109 +0,0 @@
import React, { useState } from 'react';
import SearchBar from '../components/SearchBar';
import Artist from '../models/Artist';
import Song from '../models/Song';
import Genre from '../models/Genre';
import API from '../API';
import { useQuery } from '../Queries';
import { SearchResultComponent } from '../components/SearchResult';
import { SafeAreaView } from 'react-native';
import { Filter } from '../components/SearchBar';
import { ScrollView } from 'native-base';
import LikedSong from '../models/LikedSong';
interface SearchContextType {
filter: 'artist' | 'song' | 'genre' | 'all' | 'favorites';
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all' | 'favorites') => void;
stringQuery: string;
updateStringQuery: (newData: string) => void;
songData: Song[];
artistData: Artist[];
genreData: Genre[];
favoriteData: LikedSong[];
isLoadingSong: boolean;
isLoadingArtist: boolean;
isLoadingGenre: boolean;
isLoadingFavorite: boolean;
}
export const SearchContext = React.createContext<SearchContextType>({
filter: 'all',
updateFilter: () => {},
stringQuery: '',
updateStringQuery: () => {},
songData: [],
artistData: [],
genreData: [],
favoriteData: [],
isLoadingSong: false,
isLoadingArtist: false,
isLoadingGenre: false,
isLoadingFavorite: false,
});
type SearchViewProps = {
query?: string;
};
const SearchView = (props: SearchViewProps) => {
const [filter, setFilter] = useState<Filter>('all');
const [stringQuery, setStringQuery] = useState<string>(props?.query ?? '');
const { isLoading: isLoadingSong, data: songData = [] } = useQuery(
API.searchSongs(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingArtist, data: artistData = [] } = useQuery(
API.searchArtists(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingGenre, data: genreData = [] } = useQuery(
API.searchGenres(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingFavorite, data: favoriteData = [] } = useQuery(
API.getLikedSongs(),
{ enabled: true }
);
const updateFilter = (newData: Filter) => {
// called when the filter is changed
setFilter(newData);
};
const updateStringQuery = (newData: string) => {
// called when the stringQuery is updated
setStringQuery(newData);
};
return (
<ScrollView>
<SafeAreaView>
<SearchContext.Provider
value={{
filter,
stringQuery,
songData,
artistData,
genreData,
favoriteData,
isLoadingSong,
isLoadingArtist,
isLoadingGenre,
isLoadingFavorite,
updateFilter,
updateStringQuery,
}}
>
<SearchBar />
<SearchResultComponent />
</SearchContext.Provider>
</SafeAreaView>
</ScrollView>
);
};
export default SearchView;

View File

@@ -6,6 +6,7 @@ import SongCardInfo from '../../components/V2/SongCardInfo';
import API from '../../API';
import { useNavigation } from '../../Navigation';
import GoldenRatio from '../../components/V2/GoldenRatio';
import { translate } from '../../i18n/i18n';
const HomeView = () => {
const suggestionsQuery = useQuery(
@@ -16,7 +17,6 @@ const HomeView = () => {
const isPhone = screenSize === 'small';
const topSuggestions = suggestionsQuery.data?.slice(0, 4) ?? [];
const suggestions = suggestionsQuery.data?.slice(4) ?? [];
return (
<ScrollView>
<View
@@ -25,6 +25,7 @@ const HomeView = () => {
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 8,
}}
>
<View
@@ -46,15 +47,15 @@ const HomeView = () => {
}}
>
<Text
fontSize={24}
fontWeight={'bold'}
style={{
fontSize: 24,
fontWeight: 'bold',
marginLeft: 16,
marginBottom: 16,
marginTop: 24,
}}
>
{'Suggestions'}
{translate('discoverySuggestionSectionTitle')}
</Text>
<View
style={{

View File

@@ -1,12 +1,48 @@
import React from 'react';
import { View } from 'react-native';
import { useQuery } from '../../Queries';
import SearchBarComponent from '../../components/V2/SearchBar';
import SearchHistory from '../../components/V2/SearchHistory';
import { View } from 'react-native';
import API from '../../API';
import LoadingComponent from '../../components/Loading';
import MusicListCC from '../../components/UI/MusicList';
export type searchProps = {
artist: number | undefined;
genre: number | undefined;
query: string;
};
const SearchView = () => {
const artistsQuery = useQuery(API.getAllArtists());
const [searchQuery, setSearchQuery] = React.useState({} as searchProps);
const rawResult = useQuery(API.searchSongs(searchQuery, ['artist']), {
enabled: !!searchQuery.query || !!searchQuery.artist || !!searchQuery.genre,
onSuccess() {
const artist =
artistsQuery?.data?.find(({ id }) => id == searchQuery.artist)?.name ??
'unknown artist';
searchQuery.query ? API.createSearchHistoryEntry(searchQuery.query, 'song') : null;
if (artist != 'unknown artist') API.createSearchHistoryEntry(artist, 'artist');
},
});
if (artistsQuery.isLoading) {
return <LoadingComponent />;
}
return (
<View style={{ display: 'flex', gap: 50 }}>
<SearchBarComponent />
<SearchHistory />
<View style={{ display: 'flex', gap: 20, padding: 8 }}>
<SearchBarComponent onValidate={(query) => setSearchQuery(query)} />
{rawResult.isSuccess ? (
<MusicListCC
musics={rawResult.data}
isFetching={rawResult.isFetching}
refetch={rawResult.refetch}
/>
) : (
<SearchHistory />
)}
</View>
);
};

View File

@@ -4,6 +4,7 @@ class Key:
self.start = start
self.duration = duration
self.done = False
self.half_done = False
def __repr__(self):
return f"{self.key} ({self.start} - {self.duration})"

View File

@@ -17,6 +17,11 @@ class StartMessage(ValidatedDC):
mode: Literal["normal", "practice"]
type: Literal["start"] = "start"
@dataclass
class PingMessage(ValidatedDC):
type: Literal["ping"] = "ping"
@dataclass
class EndMessage(ValidatedDC):
@@ -52,6 +57,7 @@ message_map = {
"note_on": NoteOnMessage,
"note_off": NoteOffMessage,
"pause": PauseMessage,
"ping": PingMessage,
}
@@ -62,7 +68,8 @@ def getMessage() -> (
| NoteOnMessage
| NoteOffMessage
| PauseMessage
| InvalidMessage,
| InvalidMessage
| PingMessage,
str,
]
):

View File

@@ -9,8 +9,9 @@ from mido import MidiFile
RATIO = 1.0
def getPartition(midiFile: str) -> Partition:
notes = []
def getPartition(midiFile: str, cursors) -> Partition:
notes = [Key(i["note"], cursor["timestamp"], i["duration"]) for cursor in cursors for i in cursor["notes"]]
'''
s = 0
notes_on = {}
prev_note_on = {}
@@ -29,6 +30,7 @@ def getPartition(midiFile: str) -> Partition:
note_start = notes_on[d["note"]]
notes.append(Key(d["note"], note_start, duration - 10))
notes_on[d["note"]] = s # 500
'''
return Partition(midiFile, notes)

View File

@@ -17,6 +17,7 @@ from chroma_case.Message import (
NoteOffMessage,
NoteOnMessage,
PauseMessage,
PingMessage,
StartMessage,
getMessage,
)
@@ -84,15 +85,17 @@ def send(o):
class Scorometer:
def __init__(self, mode: int, midiFile: str, song_id: int, user_id: int) -> None:
self.partition: Partition = getPartition(midiFile)
r = requests.get(f"{BACK_URL}/song/{song_id}/assets/cursors", headers=auth_header).json()
cursors = r["cursors"]
self.partition: Partition = getPartition(midiFile, cursors)
self.practice_partition: list[list[Key]] = self.getPracticePartition(mode)
# the log generated is so long that it's longer than the stderr buffer resulting in a crash
# logging.debug({"partition": self.partition.notes})
self.keys_down = []
self.keys_down: list[Key] = []
self.mode: int = mode
self.song_id: int = song_id
self.user_id: int = user_id
self.wrong_ids = []
self.wrong_ids = set()
self.difficulties = {}
self.info: ScoroInfo = {
"max_score": len(self.partition.notes) * 100,
@@ -106,6 +109,14 @@ class Scorometer:
"max_streak": 0,
}
# Practice variables
if self.mode == PRACTICE:
self.to_play = set([x.key for x in self.practice_partition.pop(0)])
else:
self.to_play = set()
self.keys_down_practice: set = set()
def send(self, obj):
obj["info"] = self.info
obj["game_id"] = str(game_uuid) if not testing else "test"
@@ -154,13 +165,14 @@ class Scorometer:
else 50
)
self.incrementStreak()
to_play.half_done = True
logging.debug({"note_on": f"{perf} on {message.note}"})
self.send({"type": "timing", "id": message.id, "timing": perf})
else:
self.info["score"] -= 25
self.info["wrong"] += 1
self.info["current_streak"] = 0
self.wrong_ids += [message.id]
self.wrong_ids.add(message.id)
logging.debug({"note_on": f"wrong key {message.note}"})
self.send({"type": "timing", "id": message.id, "timing": "wrong"})
@@ -171,8 +183,10 @@ class Scorometer:
)
self.keys_down.remove((message.note, down_since))
if message.id in self.wrong_ids:
self.wrong_ids.remove(message.id)
logging.debug({"note_off": f"wrong key {message.note}"})
self.send({"type": "duration", "id": message.id, "duration": "wrong"})
return
key = Key(
key=message.note, start=down_since, duration=(message.time - down_since)
@@ -195,67 +209,24 @@ class Scorometer:
logging.warning("note_off: no key to play but it was not a wrong note_on")
def handleNoteOnPractice(self, message: NoteOnMessage):
is_down = any(x[0] == message.note for x in self.keys_down)
logging.debug({"note_on": message.note})
if is_down:
return
self.keys_down.append((message.note, message.time))
key = Key(key=message.note, start=message.time, duration=0)
keys_to_play = next(
(i for i in self.practice_partition if any(x.done is not True for x in i)),
None,
)
if keys_to_play is None:
self.send({"type": "error", "error": "no keys should be played"})
return
to_play = next(
(i for i in keys_to_play if i.key == key.key and i.done is not True), None
)
if to_play:
perf = "practice"
logging.debug({"note_on": f"{perf} on {message.note}"})
self.incrementStreak()
self.send({"type": "timing", "id": message.id, "timing": perf})
else:
self.wrong_ids += [message.id]
logging.debug({"note_on": f"wrong key {message.note}"})
self.info["current_streak"] = 0
self.send({"type": "timing", "id": message.id, "timing": "wrong"})
self.keys_down_practice.add(message.note)
self.practiceCheck()
def handleNoteOffPractice(self, message: NoteOffMessage):
logging.debug({"note_off": message.note})
down_since = next(
since for (h_key, since) in self.keys_down if h_key == message.note
)
self.keys_down.remove((message.note, down_since))
if message.id in self.wrong_ids:
logging.debug({"note_off": f"wrong key {message.note}"})
self.send({"type": "duration", "id": message.id, "duration": "wrong"})
self.info["wrong"] += 1;
return
key = Key(
key=message.note, start=down_since, duration=(message.time - down_since)
)
keys_to_play = next(
(i for i in self.practice_partition if any(x.done is not True for x in i)),
None,
)
if keys_to_play is None:
logging.info("Invalid key.")
self.info["score"] -= 50
# TODO: I dont think this if is right
# self.sendScore(message.id, "wrong key", "wrong key")
return
to_play = next(
(i for i in keys_to_play if i.key == key.key and i.done is not True), None
)
if to_play:
perf = "practice"
to_play.done = True
logging.debug({"note_off": f"{perf} on {message.note}"})
self.send({"type": "duration", "id": message.id, "duration": perf})
else:
self.send({"type": "duration", "id": message.id, "duration": "wrong"})
self.keys_down_practice.remove(message.note)
self.practiceCheck()
def practiceCheck(self):
if self.to_play == self.keys_down_practice:
self.info["perfect"] += len(self.to_play)
self.info["score"] += 100 * len(self.to_play)
if len(self.practice_partition) == 0:
self.endGamePractice()
self.send({"type": "step", "timestamp": self.practice_partition[0][0].start + 1})
self.to_play = set([x.key for x in self.practice_partition.pop(0)])
def getDurationScore(self, key: Key, to_play: Key):
tempo_percent = abs((key.duration / to_play.duration) - 1)
@@ -287,13 +258,16 @@ class Scorometer:
| NoteOnMessage
| NoteOffMessage
| PauseMessage
| InvalidMessage,
| InvalidMessage
| PingMessage,
line: str,
):
match message:
case InvalidMessage(error):
logging.warning(f"Invalid message {line} with error: {error}")
self.send({"error": f"Invalid message {line} with error: {error}"})
case PingMessage():
self.send({"type": "pong"})
case NoteOnMessage():
if self.mode == NORMAL:
self.handleNoteOn(message)
@@ -321,7 +295,7 @@ class Scorometer:
def endGame(self):
for i in self.partition.notes:
if i.done is False:
if i.done is False and not i.half_done:
self.info["score"] -= 25
self.info["missed"] += 1
send(
@@ -345,6 +319,17 @@ class Scorometer:
headers=auth_header
)
exit()
def endGamePractice(self):
send(
{
"type": "end",
"overallScore": self.info["score"],
"precision": round(((self.info["perfect"] + self.info["great"] + self.info["good"]) / (len(self.partition.notes) + self.info["wrong"]) * 100), 2),
"score": self.info,
}
)
exit()
def handleStartMessage(start_message: StartMessage):