5 Commits

10 changed files with 257 additions and 205 deletions

View File

@@ -8,20 +8,17 @@ 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 = {
timestamp: 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 = (
@@ -37,30 +34,38 @@ const getCursorToPlay = (
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();
};
const PartitionMagic = ({
timestamp,
songID,
shouldPlay,
onEndReached,
onError,
onReady,
onPlay,
onPause,
}: ParitionMagicProps) => {
const transitionDuration = 200;
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;
@@ -72,12 +77,36 @@ const PartitionMagic = ({
const cursorTop = (data?.cursors[cursorDisplayIdx]?.y ?? 0) - cursorPaddingVertical;
const cursorLeft = (data?.cursors[0]?.x ?? 0) - cursorPaddingHorizontal;
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(() => {
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();
@@ -86,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;
});
}
@@ -115,72 +139,38 @@ const PartitionMagic = ({
}, [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) {
// 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 (!melodySound.current || !melodySound.current._loaded) return;
if (!data || data?.cursors.length === 0) return;
getCursorToPlay(
data!.cursors,
@@ -195,6 +185,7 @@ const PartitionMagic = ({
easing: Easing.inOut(Easing.ease),
}
);
if (!piano.current || !isPianoLoaded) return;
cursor.notes.forEach((note) => {
piano.current?.start({
note: note.note,
@@ -249,7 +240,7 @@ const PartitionMagic = ({
}}
>
<SvgContainer
url={getSVGURL(songID)}
url={API.getPartitionSvgUrl(songID)}
onReady={() => {
setIsPartitionSvgLoaded(true);
}}
@@ -277,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,38 +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 { atom, useAtom } from 'jotai';
import { partitionStateAtom, shouldPlayAtom } from './PartitionMagic';
import { TimestampDisplay } from './PartitionTimestampText';
export const shouldEndAtom = atom(false);
type PlayViewControlBarProps = {
song: Song;
time: number;
paused: boolean;
score: number;
disabled: boolean;
onResume: () => void;
onPause: () => void;
onEnd: () => void;
};
const PlayViewControlBar = ({
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={{
@@ -107,17 +109,20 @@ 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}
/>
{isPartitionLoading ? (
<Button isLoading size="sm" variant="solid" color={colors.coolGray[900]} />
) : (
<IconButton
size="sm"
variant="solid"
_icon={{
as: Ionicons,
color: colors.coolGray[900],
name: isPlaying ? 'pause' : 'play',
}}
onPress={() => setShouldPlay(!isPlaying)}
/>
)}
<IconButton
size="sm"
colorScheme="coolGray"
@@ -126,22 +131,10 @@ const PlayViewControlBar = ({
as: Ionicons,
name: 'stop',
}}
onPress={onEnd}
onPress={() => setShouldEnd(true)}
/>
<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>
<TimestampDisplay />
<StarProgress
value={score}
max={100}
starSteps={[50, 75, 90]}
style={{
@@ -162,17 +155,10 @@ const PlayViewControlBar = ({
minWidth: 120,
}}
>
<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

@@ -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

@@ -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

@@ -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 { PlayEndModal } from '../components/Play/PlayEndModal';
type PlayViewProps = {
songId: number;
@@ -63,22 +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 [time, setTime] = useState(0);
// const [paused, setPause] = useState<boolean>(true);
// const stopwatch = useStopwatch();
// const [time, setTime] = useState(0);
const [endResult, setEndResult] = useState<unknown>();
const [shouldPlay, setShouldPlay] = useState(false);
// const [shouldPlay, setShouldPlay] = useState(false);
const songHistory = useQuery(API.getSongHistory(songId));
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;
@@ -131,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;
}
@@ -238,14 +239,14 @@ const PlayView = ({ songId }: PlayViewProps) => {
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
const interval = setInterval(() => {
setTime(() => getElapsedTime()); // Countdown
}, 200);
// const interval = setInterval(() => {
// setTime(() => getElapsedTime()); // Countdown
// }, 200);
return () => {
ScreenOrientation.unlockAsync().catch(() => {});
stopwatch.stop();
clearInterval(interval);
// stopwatch.stop();
// clearInterval(interval);
};
}, []);
@@ -290,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')}
@@ -374,7 +376,7 @@ const PlayView = ({ songId }: PlayViewProps) => {
position: 'absolute',
}}
>
<PlayScore score={score} streak={streak} message={lastScoreMessage} />
<PlayScore />
</View>
<View
style={{
@@ -386,38 +388,36 @@ const PlayView = ({ songId }: PlayViewProps) => {
}}
>
<PartitionMagic
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
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={() => {
setShouldPlay(true);
}}
// 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}>
<Flex
flex={1}
style={{
padding: 8,
}}
>
<View
style={{
display: 'flex',
@@ -68,9 +73,15 @@ const ProfileView = () => {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 5,
}}
>
<Text fontSize={'xl'} style={{ paddingRight: 'auto' }}>
<Text
fontSize={'xl'}
isTruncated
numberOfLines={2}
style={{ flexShrink: 1 }}
>
{userQuery.data.name}
</Text>
<ButtonBase

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"