5 Commits

39 changed files with 1083 additions and 11388 deletions

View File

@@ -1,3 +1,3 @@
FROM node:18.10.0
FROM node:17
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, Put } from "@nestjs/common";
import { Controller, Get } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { ScoresService } from "./scores.service";
import { User } from "@prisma/client";
@@ -13,10 +13,4 @@ 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")
@Get("songs/:query")
@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", new ParseIntPipe({ optional: true })) artistId: number,
@Query("genreId", new ParseIntPipe({ optional: true })) genreId: number,
@Query("artistId") artistId: number,
@Query("genreId") 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")
@Get("artists/:query")
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _Artist, isArray: true })
@ApiUnauthorizedResponse({ description: "Invalid token" })

View File

@@ -5,6 +5,7 @@ volumes:
scoro_logs:
meilisearch:
services:
back:
#platform: linux/amd64
@@ -21,7 +22,7 @@ services:
depends_on:
db:
condition: service_healthy
meilisearch:
condition: service_healthy
env_file:
@@ -92,14 +93,15 @@ services:
- "4567:4567"
meilisearch:
image: getmeili/meilisearch:v1.5
image: getmeili/meilisearch:v1.4
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,4 +1,5 @@
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';
@@ -23,7 +24,6 @@ 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,6 +497,84 @@ 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
@@ -702,29 +780,6 @@ 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,8 +30,9 @@ const phoneLightGlassmorphism = {
900: 'rgb(248, 250, 254)',
1000: 'rgb(252, 254, 254)',
};
const defaultDarkGlassmorphism = {
const lightGlassmorphism =
Platform.OS === 'web' ? defaultLightGlassmorphism : phoneLightGlassmorphism;
const darkGlassmorphism = {
50: 'rgba(16,16,20,0.9)',
100: 'rgba(16,16,20,0.1)',
200: 'rgba(16,16,20,0.2)',
@@ -45,24 +46,6 @@ const defaultDarkGlassmorphism = {
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,32 +2,23 @@ import * as React from 'react';
import { Platform, View } from 'react-native';
import API from '../../API';
import { useQuery } from '../../Queries';
import Animated, {
useSharedValue,
withTiming,
Easing,
useAnimatedStyle,
} from 'react-native-reanimated';
import Animated, { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
import { CursorInfoItem } from '../../models/SongCursorInfos';
import { Audio } from 'expo-av';
import { SvgContainer } from './SvgContainer';
import LoadingComponent from '../Loading';
import { SplendidGrandPiano } from 'smplr';
import { atom, useAtom } from 'jotai';
import { useStopwatch } from 'react-use-precision-timer';
export const timestampAtom = atom(0);
export const shouldPlayAtom = atom(false);
export const partitionStateAtom = atom(
'loading' as 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error'
);
export type ParitionMagicProps = {
playType: 'practice' | 'normal' | null;
timestamp: React.MutableRefObject<number>;
songID: number;
shouldPlay: boolean;
onEndReached: () => void;
onError: (err: string) => void;
onReady: () => void;
onPlay: () => void;
onPause: () => void;
};
const getSVGURL = (songID: number) => {
return API.getPartitionSvgUrl(songID);
};
const getCursorToPlay = (
@@ -40,35 +31,41 @@ const getCursorToPlay = (
return;
}
for (let i = cursorInfos.length - 1; i > currentCurIdx; i--) {
if (cursorInfos[i]!.timestamp <= timestamp) {
return onCursorMove(cursorInfos[i]!, i);
const cursorInfo = cursorInfos[i]!;
if (cursorInfo.timestamp <= timestamp) {
onCursorMove(cursorInfo, i);
return;
}
}
};
const transitionDuration = 50;
const startResumePauseWatch = (stopwatch: ReturnType<typeof useStopwatch>, shouldPlay: boolean) => {
if (shouldPlay) {
if (stopwatch.isPaused()) {
stopwatch.resume();
return;
}
stopwatch.start();
return;
}
stopwatch.pause();
};
export let updateCursor: (() => void) | undefined = undefined;
const transitionDuration = 200;
const PartitionMagic = ({
playType,
timestamp,
songID,
shouldPlay,
onEndReached,
onError,
onReady,
onPlay,
onPause,
}: ParitionMagicProps) => {
const PartitionMagic = ({ songID }: ParitionMagicProps) => {
const { data, isLoading, isError } = useQuery(API.getSongCursorInfos(songID));
const currentCurIdx = React.useRef(-1);
const stopwatch = useStopwatch();
const [endPartitionReached, setEndPartitionReached] = React.useState(false);
const [isPartitionSvgLoaded, setIsPartitionSvgLoaded] = React.useState(false);
const partitionOffset = useSharedValue(0);
const melodySound = React.useRef<Audio.Sound | null>(null);
const piano = React.useRef<SplendidGrandPiano | null>(null);
const [isPianoLoaded, setIsPianoLoaded] = React.useState(false);
const [timestamp, setTimestamp] = useAtom(timestampAtom);
const [shouldPlay, setShouldPlay] = useAtom(shouldPlayAtom);
const [partitionState, setPartitionState] = useAtom(partitionStateAtom);
const cursorPaddingVertical = 10;
const cursorPaddingHorizontal = 3;
@@ -80,49 +77,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,
});
});
}
);
};
console.log('state', partitionState, timestamp);
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 :(
setEndPartitionReached(true);
melodySound.current?.pauseAsync();
piano.current?.stop();
setPartitionState('ended');
}
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 (isError) {
setPartitionState('error');
}
}, [isError]);
React.useEffect(() => {
if (isPartitionSvgLoaded && !isLoading && (melodySound.current?._loaded || isPianoLoaded)) {
setPartitionState('ready');
} else if (partitionState !== 'loading') {
setPartitionState('loading');
}
return () => {
setPartitionState('loading');
setTimestamp(0);
setShouldPlay(false);
};
}, [isPartitionSvgLoaded, isLoading, melodySound.current?._loaded, isPianoLoaded]);
React.useEffect(() => {
if (Platform.OS === 'web' && !piano.current) {
const audio = new AudioContext();
piano.current = new SplendidGrandPiano(audio);
@@ -130,14 +115,9 @@ const PartitionMagic = ({
setIsPianoLoaded(true);
});
} else if (!melodySound.current) {
Audio.Sound.createAsync(
{
uri: API.getPartitionMelodyUrl(songID),
},
{
progressUpdateIntervalMillis: 200,
}
).then((track) => {
Audio.Sound.createAsync({
uri: API.getPartitionMelodyUrl(songID),
}).then((track) => {
melodySound.current = track.sound;
});
}
@@ -153,55 +133,69 @@ const PartitionMagic = ({
piano.current = null;
}
};
}, [playType]);
}, []);
const partitionDims = React.useMemo<[number, number]>(() => {
return [data?.pageWidth ?? 0, data?.pageHeight ?? 1];
}, [data]);
React.useEffect(() => {
if (onError && isError) {
onError('Error while loading partition');
return;
}
}, [onError, isError]);
React.useEffect(() => {
if (isPartitionSvgLoaded && !isLoading && (melodySound.current?._loaded || isPianoLoaded)) {
onReady();
}
}, [isPartitionSvgLoaded, isLoading, melodySound.current?._loaded, isPianoLoaded]);
const interval = setInterval(
() => {
// if (partitionState !== 'playing') return;
setTimestamp(stopwatch.getElapsedRunningTime());
},
Platform.OS === 'web' ? 200 : 500
);
return () => {
clearInterval(interval);
};
}, []);
React.useEffect(() => {
if (Platform.OS === 'web') {
if (!piano.current || !isPianoLoaded) {
return;
}
shouldPlay ? onPlay() : onPause();
return;
}
if (!melodySound.current || !melodySound.current._loaded) {
if (!piano.current || !isPianoLoaded) return;
setPartitionState(shouldPlay ? 'playing' : 'paused');
startResumePauseWatch(stopwatch, shouldPlay);
return;
}
if (!melodySound.current || !melodySound.current._loaded) return;
setPartitionState(shouldPlay ? 'playing' : 'paused');
startResumePauseWatch(stopwatch, shouldPlay);
if (shouldPlay) {
melodySound.current.playAsync().then(onPlay).catch(console.error);
melodySound.current.playAsync().catch(console.error);
} else {
melodySound.current.pauseAsync().then(onPause).catch(console.error);
melodySound.current.pauseAsync().catch(console.error);
}
}, [shouldPlay]);
React.useEffect(() => {
if (endPartitionReached && playType === 'normal') {
// if the audio is unsync
melodySound.current?.pauseAsync();
onEndReached();
}
}, [endPartitionReached]);
if (!shouldPlay) return;
if (!melodySound.current || !melodySound.current._loaded) 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),
}
);
if (!piano.current || !isPianoLoaded) return;
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={{
@@ -235,20 +229,18 @@ const PartitionMagic = ({
}}
>
<Animated.View
style={[
animatedStyle,
{
position: 'absolute',
height: '100%',
aspectRatio: partitionDims[0] / partitionDims[1],
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
},
]}
style={{
position: 'absolute',
height: '100%',
aspectRatio: partitionDims[0] / partitionDims[1],
left: `${partitionOffset.value * 100}%`,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
>
<SvgContainer
url={getSVGURL(songID)}
url={API.getPartitionSvgUrl(songID)}
onReady={() => {
setIsPartitionSvgLoaded(true);
}}
@@ -276,11 +268,4 @@ const PartitionMagic = ({
);
};
PartitionMagic.defaultProps = {
onError: () => {},
onReady: () => {},
onPlay: () => {},
onPause: () => {},
};
export default PartitionMagic;

View File

@@ -0,0 +1,31 @@
import { Text, useTheme } from 'native-base';
import { timestampAtom, partitionStateAtom } from './PartitionMagic';
import { useAtom } from 'jotai';
export const TimestampDisplay = () => {
const [time] = useAtom(timestampAtom);
const [partitionState] = useAtom(partitionStateAtom);
const { colors } = useTheme();
const textColor = colors.text;
const paused = partitionState === 'paused';
if (time < 0) {
if (paused) {
return <Text color={textColor[900]}>0:00</Text>;
}
return (
<Text color={textColor[900]}>
{Math.floor((time % 60000) / 1000)
.toFixed(0)
.toString()}
</Text>
);
}
return (
<Text color={textColor[900]}>
{`${Math.floor(time / 60000)}:${Math.floor((time % 60000) / 1000)
.toFixed(0)
.toString()
.padStart(2, '0')}`}
</Text>
);
};

View File

@@ -0,0 +1,31 @@
import PopupCC from '../UI/PopupCC';
import { shouldEndAtom } from './PlayViewControlBar';
import { partitionStateAtom } from './PartitionMagic';
import ScoreModal from '../ScoreModal';
import { useAtom } from 'jotai';
export const PlayEndModal = () => {
const [shouldEnd] = useAtom(shouldEndAtom);
const [partitionState] = useAtom(partitionStateAtom);
const isEnd = shouldEnd || partitionState === 'ended';
return (
<PopupCC isVisible={isEnd}>
<ScoreModal
songId={0}
overallScore={0}
precision={0}
score={{
max_score: 0,
missed: 0,
wrong: 0,
good: 0,
great: 0,
perfect: 0,
current_streak: 0,
max_streak: 0,
}}
/>
</PopupCC>
);
};

View File

@@ -10,20 +10,21 @@ import Animated, {
Easing,
} from 'react-native-reanimated';
import { ColorSchemeType } from 'native-base/lib/typescript/components/types';
import { atom, useAtom } from 'jotai';
export const scoreMessageAtom = atom<ScoreMessage | null>(null);
export const scoreAtom = atom(0);
export type ScoreMessage = {
content: string;
color?: ColorSchemeType;
id: number;
};
type PlayScoreProps = {
score: number;
timestamp: number;
streak: number;
message?: ScoreMessage;
};
export const PlayScore = ({ score, streak, message }: PlayScoreProps) => {
export const PlayScore = () => {
const [message] = useAtom(scoreMessageAtom);
const [score] = useAtom(scoreAtom);
const scoreMessageScale = useSharedValue(0);
// this style should bounce in on enter and fade away
const scoreMsgStyle = useAnimatedStyle(() => {
@@ -92,9 +93,9 @@ export const PlayScore = ({ score, streak, message }: PlayScoreProps) => {
<Text color={textColor[900]} fontSize={20}>
{message.content}
</Text>
{streak > 0 && (
{message.streak > 0 && (
<Text color={textColor[900]} fontSize={15} bold>
{`x${streak}`}
{`x${message.streak}`}
</Text>
)}
</View>

View File

@@ -1,35 +0,0 @@
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

@@ -1,41 +1,40 @@
import { View } from 'react-native';
import * as React from 'react';
import { Row, Image, Text, useBreakpointValue, IconButton } from 'native-base';
import { Row, Image, Text, useBreakpointValue, IconButton, Button } from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { MetronomeControls } from '../Metronome';
import StarProgress from '../StarProgress';
import Song from '../../models/Song';
import { useTheme } from 'native-base';
import { PlayTimestampShow } from './PlayTimestampShow';
import { atom, useAtom } from 'jotai';
import { partitionStateAtom, shouldPlayAtom } from './PartitionMagic';
import { TimestampDisplay } from './PartitionTimestampText';
export const shouldEndAtom = atom(false);
type PlayViewControlBarProps = {
playType: 'practice' | 'normal' | null;
song: Song;
time: React.MutableRefObject<number>;
paused: boolean;
score: number;
disabled: boolean;
onResume: () => void;
onPause: () => void;
onEnd: () => void;
};
const PlayViewControlBar = ({
playType,
song,
time,
paused,
score,
disabled,
onResume,
onPause,
onEnd,
}: PlayViewControlBarProps) => {
const PlayViewControlBar = ({ song }: PlayViewControlBarProps) => {
const [, setShouldPlay] = useAtom(shouldPlayAtom);
const [partitionState] = useAtom(partitionStateAtom);
const [, setShouldEnd] = useAtom(shouldEndAtom);
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';
const bpm = React.useRef<number>(60);
const { colors } = useTheme();
const textColor = colors.text;
const isPlaying = partitionState === 'playing';
const isPartitionLoading = partitionState === 'loading';
React.useEffect(() => {
return () => {
setShouldPlay(false);
setShouldEnd(false);
};
}, []);
return (
<Row
style={{
@@ -110,17 +109,18 @@ const PlayViewControlBar = ({
gap: isPhone ? 10 : 25,
}}
>
{playType != 'practice' && (
{isPartitionLoading ? (
<Button isLoading size="sm" variant="solid" color={colors.coolGray[900]} />
) : (
<IconButton
size="sm"
variant="solid"
disabled={disabled}
_icon={{
as: Ionicons,
color: colors.coolGray[900],
name: paused ? 'play' : 'pause',
name: isPlaying ? 'pause' : 'play',
}}
onPress={paused ? onResume : onPause}
onPress={() => setShouldPlay(!isPlaying)}
/>
)}
<IconButton
@@ -131,11 +131,10 @@ const PlayViewControlBar = ({
as: Ionicons,
name: 'stop',
}}
onPress={onEnd}
onPress={() => setShouldEnd(true)}
/>
<PlayTimestampShow paused={paused} time={time} />
<TimestampDisplay />
<StarProgress
value={score}
max={100}
starSteps={[50, 75, 90]}
style={{
@@ -156,17 +155,10 @@ const PlayViewControlBar = ({
minWidth: 120,
}}
>
{playType != 'practice' && <MetronomeControls paused={paused} bpm={bpm.current} />}
<MetronomeControls paused={isPlaying} bpm={bpm.current} />
</View>
</Row>
);
};
PlayViewControlBar.defaultProps = {
onResume: () => {},
onPause: () => {},
onEnd: () => {},
disabled: false,
};
export default PlayViewControlBar;

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).toFixed(2)}%</Text>
<Text fontSize="3xl">{Math.max(score, 0)}%</Text>
<Row w="100%" style={{ justifyContent: 'space-between' }}>
<Translate translationKey="precision" />
<Text>{props.precision}%</Text>

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,277 @@
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

@@ -2,15 +2,17 @@ import * as React from 'react';
import { Progress } from 'native-base';
import { View, ViewStyle, StyleProp } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { scoreAtom } from './Play/PlayScore';
import { useAtom } from 'jotai';
export interface StarProgressProps {
value: number;
max: number;
starSteps: number[];
style?: StyleProp<ViewStyle>;
}
const StarProgress = (props: StarProgressProps) => {
const [score] = useAtom(scoreAtom);
return (
<View
style={[
@@ -29,15 +31,15 @@ const StarProgress = (props: StarProgressProps) => {
style={{
flex: 1,
}}
value={props.value}
value={score}
max={props.max}
/>
{props.starSteps.map((step) => {
return (
<Ionicons
key={step}
name={step <= props.value ? 'star' : 'star-outline'}
color={step <= props.value ? '#EBDA3C' : '#6075F9'}
name={step <= score ? 'star' : 'star-outline'}
color={step <= score ? '#EBDA3C' : '#6075F9'}
size={20}
style={{
position: 'absolute',

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ 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';
@@ -39,7 +38,6 @@ 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')
@@ -86,7 +84,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
)}
</Row>
<ButtonBase
title={t('guestMode')}
title={translate('guestMode')}
onPress={async () => {
try {
handleGuestLogin((accessToken: string) => {
@@ -154,7 +152,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
title={translate('continuewithgoogle')}
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
/>
<SeparatorBase>{t('authOrSection')}</SeparatorBase>
<SeparatorBase>or</SeparatorBase>
<Stack
space={3}
justifyContent="center"

View File

@@ -9,7 +9,7 @@ interface TextFormFieldProps extends TextFieldBaseProps {
error: string | null;
}
const ERROR_HEIGHT = 25;
const ERROR_HEIGHT = 20;
const ERROR_PADDING_TOP = 8;
const TextFormField: React.FC<TextFormFieldProps> = ({ error, style, ...textFieldBaseProps }) => {
@@ -49,12 +49,7 @@ const TextFormField: React.FC<TextFormFieldProps> = ({ error, style, ...textFiel
}}
>
<Warning2 size="16" color="#f7253d" variant="Bold" />
<Text
isTruncated
maxW={'100%'}
fontSize={styles.errorText.fontSize}
color={styles.errorText.color}
>
<Text isTruncated maxW={'100%'} style={styles.errorText}>
{error}
</Text>
</Animated.View>
@@ -67,14 +62,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="24" color={'#ED4A51'} />
<CloseCircle size="32" color={'#ED4A51'} />
) : (
<AddSquare size="24" color={'#6075F9'} />
<AddSquare size="32" color={'#6075F9'} />
)}
<Text>{props.name}</Text>
</View>
@@ -40,24 +40,15 @@ const ArtistChipComponent = (props: ArtistChipProps) => {
);
};
const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => void }) => {
const SearchBarComponent = () => {
const [query, setQuery] = React.useState('');
const [genre, setGenre] = React.useState('');
const [genre, setGenre] = React.useState({} as Genre | undefined);
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
@@ -66,48 +57,62 @@ const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => vo
borderBottomColor: '#9E9E9E',
display: 'flex',
flexDirection: isMobileView ? 'column' : 'row',
alignItems: 'center',
width: '100%',
margin: 5,
padding: isMobileView ? 8 : 16,
padding: 16,
gap: 10,
}}
>
{!!artist && (
<View
style={{
flexGrow: 0,
flexShrink: 0,
flexDirection: 'row',
flexWrap: 'nowrap',
maxWidth: '100%',
}}
>
<View
style={{
flexGrow: 0,
flexShrink: 0,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{artist && (
<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 }}>
<View
style={{
flexGrow: 1,
flexShrink: 1,
}}
>
<Input
type="text"
value={query}
variant={'unstyled'}
placeholder={translate('searchBarPlaceholder')}
style={{ height: 30, flex: 1 }}
style={{ width: '100%', height: 30 }}
onChangeText={(value) => setQuery(value)}
/>
</View>
<ButtonBase type="menu" icon={SearchNormal1} onPress={handleValidate} />
<ButtonBase
type="menu"
icon={SearchNormal1}
style={{
flexShrink: 0,
flexGrow: 0,
}}
/>
</View>
</View>
<View
@@ -141,13 +146,6 @@ const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => vo
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);
}}
/>
@@ -156,20 +154,15 @@ const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => vo
</ScrollView>
<View>
<Select
selectedValue={genre}
selectedValue={genre?.name}
placeholder={translate('genreFilter')}
accessibilityLabel="Genre"
onValueChange={(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,
});
setGenre(
genresQuery.data?.find((genre) => {
genre.name == itemValue;
})
);
}}
>
<Select.Item label={translate('emptySelection')} value="" />

View File

@@ -52,10 +52,12 @@ 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
@@ -84,7 +86,14 @@ export const BoardRow = ({ userAvatarUrl, userPseudo, userLvl, index }: BoardRow
justifyContent: 'center',
}}
>
<Text fontSize={16} textAlign={'center'}>
<Text
style={{
fontSize: 16,
fontStyle: 'normal',
fontWeight: '500',
textAlign: 'center',
}}
>
{index}
</Text>
</View>

View File

@@ -62,7 +62,15 @@ export const PodiumCard = ({
</Avatar>
</View>
<View>
<Text fontSize={20} numberOfLines={2} isTruncated>
<Text
style={{
fontSize: 20,
fontWeight: '500',
maxWidth: '100%',
}}
numberOfLines={2}
isTruncated
>
{userPseudo ?? '---'}
</Text>
<View
@@ -74,7 +82,12 @@ export const PodiumCard = ({
gap: 5,
}}
>
<Text fontSize={24} fontWeight={'bold'}>
<Text
style={{
fontSize: 24,
fontWeight: '500',
}}
>
{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,8 +321,6 @@ 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 = {
@@ -645,12 +643,10 @@ 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 Profil',
updateProfile: 'Changer le Profile',
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 = {
@@ -984,6 +980,4 @@ 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

@@ -38,6 +38,7 @@
"fbjs": "^3.0.5",
"i18next": "^23.5.1",
"iconsax-react-native": "^0.0.8",
"jotai": "^2.6.1",
"native-base": "^3.4.28",
"normalize-css-color": "^1.0.2",
"react": "18.2.0",

View File

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

View File

@@ -29,21 +29,22 @@ const Leaderboardiew = () => {
] as const;
return (
<ScrollView
style={{
padding: 8,
}}
>
<ScrollView>
<Text
fontSize={20}
fontWeight={'500'}
style={{
fontSize: 20,
fontWeight: '500',
marginBottom: 16,
}}
>
{translate('leaderBoardHeading')}
</Text>
<Text fontSize={14} fontWeight={'500'}>
<Text
style={{
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, { updateCursor } from '../components/Play/PartitionMagic';
import PartitionMagic from '../components/Play/PartitionMagic';
import useColorScheme from '../hooks/colorScheme';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme, useBreakpointValue } from 'native-base';
@@ -25,7 +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';
import { PlayEndModal } from '../components/Play/PlayEndModal';
type PlayViewProps = {
songId: number;
@@ -33,7 +33,6 @@ 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) => {
@@ -65,24 +64,22 @@ const PlayView = ({ songId }: PlayViewProps) => {
const isPhone = screenSize === 'small';
const song = useQuery(API.getSong(songId, ['artist']), { staleTime: Infinity });
const toast = useToast();
const [lastScoreMessage, setLastScoreMessage] = useState<ScoreMessage>();
// const [lastScoreMessage, setLastScoreMessage] = useState<ScoreMessage>();
const webSocket = useRef<WebSocket>();
const [paused, setPause] = useState<boolean>(true);
const stopwatch = useStopwatch();
// const [paused, setPause] = useState<boolean>(true);
// const stopwatch = useStopwatch();
// const [time, setTime] = useState(0);
const time = useRef(0);
const [endResult, setEndResult] = useState<unknown>();
const [shouldPlay, setShouldPlay] = useState(false);
// 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);
// const [score, setScore] = useState(0); // Between 0 and 100
// const getElapsedTime = () => stopwatch.getElapsedRunningTime();
// const [readyToPlay, setReadyToPlay] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [midiKeyboardFound, setMidiKeyboardFound] = useState<boolean>();
// first number is the note, the other is the time when pressed on release the key is removed
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [streak, setStreak] = useState(0);
// const [streak, setStreak] = useState(0);
const colorScheme = useColorScheme();
const { colors } = useTheme();
const statColor = colors.lightText;
@@ -116,8 +113,6 @@ 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');
@@ -137,7 +132,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
console.log('MIDI inputs', inputs);
let endMsgReceived = false; // Used to know if to go to error screen when websocket closes
if (inputs.size <= 0) {
if (inputs.size <= 10) {
toast.show({ description: 'No MIDI Keyboard found' });
return;
}
@@ -152,16 +147,8 @@ 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' });
@@ -179,30 +166,19 @@ 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;
@@ -243,9 +219,8 @@ const PlayView = ({ songId }: PlayViewProps) => {
};
inputs.forEach((input) => {
input.onmidimessage = (message) => {
const { command, note, velocity } = parseMidiMessage(message);
let keyIsPressed = command == 9;
if (velocity == 0) keyIsPressed = false;
const { command, note } = parseMidiMessage(message);
const keyIsPressed = command == 9;
webSocket.current?.send(
JSON.stringify({
@@ -262,23 +237,16 @@ const PlayView = ({ songId }: PlayViewProps) => {
toast.show({ description: `Failed to get MIDI access` });
};
useEffect(() => {
if (playType == 'practice') return;
const interval = setInterval(() => {
time.current = getElapsedTime();
updateCursor?.();
updateTimeControlBar?.();
}, 200);
return () => clearInterval(interval);
}, [playType]);
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
// const interval = setInterval(() => {
// setTime(() => getElapsedTime()); // Countdown
// }, 200);
return () => {
ScreenOrientation.unlockAsync().catch(() => {});
stopwatch.stop();
// stopwatch.stop();
// clearInterval(interval);
};
}, []);
@@ -323,12 +291,13 @@ const PlayView = ({ songId }: PlayViewProps) => {
zIndex: 100,
}}
>
<PopupCC isVisible={endResult != undefined}>
<PlayEndModal />
{/* <PopupCC isVisible={endResult != undefined}>
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(() => (endResult ? <ScoreModal {...(endResult as any)} /> : <></>))()
endResult ? <ScoreModal {...(endResult as any)} /> : <></>
}
</PopupCC>
</PopupCC> */}
<PopupCC
title={translate('selectPlayMode')}
description={translate('selectPlayModeExplaination')}
@@ -349,10 +318,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
style={{}}
type="outlined"
title={translate('practiceBtn')}
onPress={async () => {
setPlayType('practice');
setShouldPlay(true);
}}
onPress={async () => setPlayType('practice')}
/>
<ButtonBase
style={{}}
@@ -410,7 +376,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
position: 'absolute',
}}
>
<PlayScore score={score} streak={streak} message={lastScoreMessage} />
<PlayScore />
</View>
<View
style={{
@@ -422,42 +388,36 @@ const PlayView = ({ songId }: PlayViewProps) => {
}}
>
<PartitionMagic
playType={playType}
shouldPlay={shouldPlay}
timestamp={time}
songID={song.data.id}
onEndReached={() => {
setTimeout(() => {
onEnd();
}, 200);
}}
onError={() => {
console.log('error from partition magic');
}}
onReady={() => {
console.log('ready from partition magic');
setReadyToPlay(true);
}}
onPlay={onResume}
onPause={onPause}
// onEndReached={() => {
// setTimeout(() => {
// onEnd();
// }, 200);
// }}
// onError={() => {
// console.log('error from partition magic');
// }}
// onReady={() => {
// console.log('ready from partition magic');
// setReadyToPlay(true);
// }}
// onPlay={onResume}
// onPause={onPause}
/>
</View>
<PlayViewControlBar
playType={playType}
score={score}
time={time}
paused={paused}
disabled={playType == null || !readyToPlay}
// score={score}
// time={time}
// paused={paused}
// disabled={playType == null || !readyToPlay}
song={song.data}
onEnd={onEnd}
onPause={() => {
setShouldPlay(false);
}}
onResume={() => {
setTimeout(() => {
setShouldPlay(true);
}, 3000);
}}
// onEnd={onEnd}
// onPause={() => {
// setShouldPlay(false);
// }}
// onResume={() => {
// setShouldPlay(true);
// }}
/>
</SafeAreaView>
{colorScheme === 'dark' && (

View File

@@ -34,7 +34,12 @@ const ProfileView = () => {
const isBigScreen = layout.width > 650;
return (
<Flex flex={1} padding={8}>
<Flex
flex={1}
style={{
padding: 8,
}}
>
<View
style={{
display: 'flex',
@@ -71,7 +76,12 @@ const ProfileView = () => {
gap: 5,
}}
>
<Text fontSize={'xl'} isTruncated numberOfLines={2} flexShrink={1}>
<Text
fontSize={'xl'}
isTruncated
numberOfLines={2}
style={{ flexShrink: 1 }}
>
{userQuery.data.name}
</Text>
<ButtonBase
@@ -81,15 +91,10 @@ const ProfileView = () => {
/>
</View>
<Translate
style={{ fontWeight: 'bold' }}
style={{ paddingBottom: 10, 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',

109
front/views/SearchView.tsx Normal file
View File

@@ -0,0 +1,109 @@
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,7 +6,6 @@ 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(
@@ -17,6 +16,7 @@ const HomeView = () => {
const isPhone = screenSize === 'small';
const topSuggestions = suggestionsQuery.data?.slice(0, 4) ?? [];
const suggestions = suggestionsQuery.data?.slice(4) ?? [];
return (
<ScrollView>
<View
@@ -25,7 +25,6 @@ const HomeView = () => {
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 8,
}}
>
<View
@@ -47,15 +46,15 @@ const HomeView = () => {
}}
>
<Text
fontSize={24}
fontWeight={'bold'}
style={{
fontSize: 24,
fontWeight: 'bold',
marginLeft: 16,
marginBottom: 16,
marginTop: 24,
}}
>
{translate('discoverySuggestionSectionTitle')}
{'Suggestions'}
</Text>
<View
style={{

View File

@@ -1,48 +1,12 @@
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 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;
};
import { View } from 'react-native';
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: 20, padding: 8 }}>
<SearchBarComponent onValidate={(query) => setSearchQuery(query)} />
{rawResult.isSuccess ? (
<MusicListCC
musics={rawResult.data}
isFetching={rawResult.isFetching}
refetch={rawResult.refetch}
/>
) : (
<SearchHistory />
)}
<View style={{ display: 'flex', gap: 50 }}>
<SearchBarComponent />
<SearchHistory />
</View>
);
};

View File

@@ -7771,6 +7771,11 @@ join-component@^1.1.0:
resolved "https://registry.yarnpkg.com/join-component/-/join-component-1.1.0.tgz#b8417b750661a392bee2c2537c68b2a9d4977cd5"
integrity sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==
jotai@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.6.1.tgz#ece33a50b604e41b0134f94dd621e55d1bdc66f7"
integrity sha512-GLQtAnA9iEKRMXnyCjf1azIxfQi5JausX2EI5qSlb59j4i73ZEyV/EXPDEAQj4uQNZYEefi3degv/Pw3+L/Dtg==
js-sha3@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"

View File

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

View File

@@ -9,9 +9,8 @@ from mido import MidiFile
RATIO = 1.0
def getPartition(midiFile: str, cursors) -> Partition:
notes = [Key(i["note"], cursor["timestamp"], i["duration"]) for cursor in cursors for i in cursor["notes"]]
'''
def getPartition(midiFile: str) -> Partition:
notes = []
s = 0
notes_on = {}
prev_note_on = {}
@@ -30,7 +29,6 @@ def getPartition(midiFile: str, cursors) -> 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,7 +17,6 @@ from chroma_case.Message import (
NoteOffMessage,
NoteOnMessage,
PauseMessage,
PingMessage,
StartMessage,
getMessage,
)
@@ -85,17 +84,15 @@ def send(o):
class Scorometer:
def __init__(self, mode: int, midiFile: str, song_id: int, user_id: int) -> None:
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.partition: Partition = getPartition(midiFile)
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: list[Key] = []
self.keys_down = []
self.mode: int = mode
self.song_id: int = song_id
self.user_id: int = user_id
self.wrong_ids = set()
self.wrong_ids = []
self.difficulties = {}
self.info: ScoroInfo = {
"max_score": len(self.partition.notes) * 100,
@@ -109,14 +106,6 @@ 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"
@@ -165,14 +154,13 @@ 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.add(message.id)
self.wrong_ids += [message.id]
logging.debug({"note_on": f"wrong key {message.note}"})
self.send({"type": "timing", "id": message.id, "timing": "wrong"})
@@ -183,10 +171,8 @@ 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)
@@ -209,24 +195,67 @@ 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})
self.keys_down_practice.add(message.note)
self.practiceCheck()
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"})
def handleNoteOffPractice(self, message: NoteOffMessage):
logging.debug({"note_off": message.note})
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)])
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"})
def getDurationScore(self, key: Key, to_play: Key):
tempo_percent = abs((key.duration / to_play.duration) - 1)
@@ -258,16 +287,13 @@ class Scorometer:
| NoteOnMessage
| NoteOffMessage
| PauseMessage
| InvalidMessage
| PingMessage,
| InvalidMessage,
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)
@@ -295,7 +321,7 @@ class Scorometer:
def endGame(self):
for i in self.partition.notes:
if i.done is False and not i.half_done:
if i.done is False:
self.info["score"] -= 25
self.info["missed"] += 1
send(
@@ -319,17 +345,6 @@ 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):