diff --git a/app.json b/app.json index c930458a7a7a4c98a26c08f93a60de55ca561be3..af7c947ebf7db5c20672dee86e8922622985be5a 100644 --- a/app.json +++ b/app.json @@ -13,13 +13,15 @@ "backgroundColor": "#ffffff" }, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.lykomonix.vili" }, "android": { "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" - } + }, + "package": "com.lykomonix.vili" }, "web": { "favicon": "./assets/favicon.png" diff --git a/components/buttons/AnswerButton.tsx b/components/buttons/AnswerButton.tsx index 5b861da586b2e3dc37366d3ea982c9ac136b67ba..fb4c08a5a3a08db741b9b7847d38ab28c38f81cb 100644 --- a/components/buttons/AnswerButton.tsx +++ b/components/buttons/AnswerButton.tsx @@ -2,6 +2,8 @@ import {TouchableOpacity, Text, StyleSheet, View, Image} from "react-native"; import {ButtonsStyles} from "../../styles/ButtonsStyles"; import {TextsStyles} from "../../styles/TextsStyles"; import React from "react"; +import Base64AudioPlayer from "./Base64AudioPlayer"; + interface Props{ text: string; @@ -9,6 +11,7 @@ interface Props{ buttonStyle?: any; buttonText?: any; indicator?: number | undefined; + forceEnd: boolean; } /** @@ -18,7 +21,7 @@ interface Props{ * @param buttonStyle - Surcharge du style du bouton * @param buttonText - Surcharge du style du texte du bouton */ -export default function AnswerButton({text, handleButtonPressed, buttonStyle, buttonText, indicator}: Props){ +export default function AnswerButton({text, handleButtonPressed, buttonStyle, buttonText, indicator, forceEnd}: Props){ return( <View style={styles.containerButton}> <TouchableOpacity onPress={handleButtonPressed} style={[styles.defaultButton,buttonStyle]}> @@ -35,9 +38,9 @@ export default function AnswerButton({text, handleButtonPressed, buttonStyle, bu /> : text.includes('data:audio') ? - <Text style={[TextsStyles.defaultButtonText, buttonText]}>sound: not supported</Text> + <Base64AudioPlayer base64Audio={text} forceEnd={forceEnd}/> : - <Text style={[TextsStyles.defaultButtonText, buttonText]}>{text}</Text> + <Text style={[TextsStyles.defaultButtonText, buttonText]}>{text}</Text> } </TouchableOpacity> </View> diff --git a/components/buttons/AnswerButtonImage.tsx b/components/buttons/AnswerButtonImage.tsx deleted file mode 100644 index 8716144bd818a697248cf67eddd8e6a29e1b0cb3..0000000000000000000000000000000000000000 --- a/components/buttons/AnswerButtonImage.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import {TouchableOpacity, Text, StyleSheet, View, Image} from "react-native"; -import {ButtonsStyles} from "../../styles/ButtonsStyles"; -import {TextsStyles} from "../../styles/TextsStyles"; - -interface Props{ - url: string; - handleButtonPressed:() => void; - buttonStyle?: any; - indicator?: number | undefined; -} - -/** - * AnswerButtonImage : Un bouton par défaut, il possède déjà un style mais on peut le surcharger grâce à la propriété buttonStyle - * @param url - Texte du bouton - * @param handleButtonPressed - Fonction qui sera exécutée lorsque le bouton sera pressé - * @param buttonStyle - Surcharge du style du bouton - * @param buttonText - Surcharge du style du texte du bouton - */ -export default function AnswerButtonImage({url, handleButtonPressed, buttonStyle, indicator}: Props){ - return( - <View style={styles.containerButton}> - <TouchableOpacity onPress={handleButtonPressed} style={[styles.defaultButton,buttonStyle]}> - {indicator !== undefined && ( - <View style={styles.indicator}> - <Text style={styles.indicatorText}>{indicator}</Text> - </View> - )} - <Image - style={[styles.answerImage]} - source={{uri: url}} - /> - </TouchableOpacity> - </View> - ) -} - -const styles = StyleSheet.create({ - defaultButton: { - backgroundColor: "#45128C", - padding: 10, - alignItems: "center", - justifyContent: 'center', - borderRadius: 10, - }, - indicator: { - position: "absolute", - top: "-16%", - left: "5%", - width: 25, - height: 25, - borderRadius: 15, - backgroundColor: "#D3D3D3", - justifyContent: "center", - alignItems: "center", - zIndex: 1, - }, - indicatorText: { - color: "#000", - fontWeight: "bold", - fontSize: 14, - }, - answerImage: { - width: '100%', - height: '100%', - }, - containerButton: { - height: 150, - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - } -}) \ No newline at end of file diff --git a/components/buttons/Base64AudioPlayer.tsx b/components/buttons/Base64AudioPlayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba0e71b59b3b257456ff098439af3bc871a28f55 --- /dev/null +++ b/components/buttons/Base64AudioPlayer.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from 'react'; +import { View, Button, Text } from 'react-native'; +import { Audio } from 'expo-av'; + +interface Props { + base64Audio: string; + forceEnd: boolean; +} + +export default function Base64AudioPlayer({ base64Audio, forceEnd }: Props) { + const [sound, setSound] = useState<Audio.Sound | null>(null); + const [isPlaying, setIsPlaying] = useState(false); + + const playAudio = async () => { + try { + const { sound } = await Audio.Sound.createAsync( + { uri: `${base64Audio}` } + ); + setSound(sound); + + await sound.playAsync(); + setIsPlaying(true); + + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && !status.isBuffering && status.didJustFinish) { + setIsPlaying(false); + } + }); + } catch (error) { + console.error('Erreur lors de la lecture de l\'audio :', error); + } + }; + + const stopAudio = async () => { + if (sound) { + await sound.stopAsync(); + setIsPlaying(false); + } + }; + + useEffect(() => { + stopAudio(); + }, [forceEnd]); + + return ( + <View style={{ padding: 20 }}> + <Text style={{ marginBottom: 10 }}>Appuyer pour jouer l'audio</Text> + <Button title={isPlaying ? 'Arrêter l\'audio' : 'Jouer l\'audio'} onPress={isPlaying ? stopAudio : playAudio} /> + </View> + ); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2a605a2226b077d10f3560a61b36c7d1b97e4f1d..1fa047132afbd03c21c6db817cfcd93f67234e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "axios": "^1.7.9", "dotenv": "^16.4.7", "expo": "~52.0.14", + "expo-av": "~15.0.2", "expo-splash-screen": "^0.29.13", "expo-status-bar": "~2.0.0", "he": "^1.2.0", @@ -28,9 +29,11 @@ "react-i18next": "^15.1.1", "react-native": "0.76.3", "react-native-dropdown-select-list": "^2.0.5", + "react-native-fs": "^2.20.0", "react-native-progress": "^5.0.1", "react-native-safe-area-context": "^4.12.0", "react-native-screens": "~4.1.0", + "react-native-sound": "^0.11.2", "react-native-sse": "^1.2.1", "react-native-vector-icons": "^10.2.0", "react-query": "^3.39.3", @@ -4662,6 +4665,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6126,6 +6134,23 @@ "react-native": "*" } }, + "node_modules/expo-av": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-15.0.2.tgz", + "integrity": "sha512-AHIHXdqLgK1dfHZF0JzX3YSVySGMrWn9QtPzaVjw54FAzvXfMt4sIoq4qRL/9XWCP9+ICcCs/u3EcvmxQjrfcA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.3.tgz", @@ -9740,6 +9765,25 @@ "integrity": "sha512-TepbcagQVUMB6nLuIlVU2ghRpQHAECOeZWe8K04ymW6NqbKbxuczZSDFfdCiABiiQ2dFD+8Dz65y4K7/uUEqGg==", "license": "MIT" }, + "node_modules/react-native-fs": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", + "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", + "license": "MIT", + "dependencies": { + "base-64": "^0.1.0", + "utf8": "^3.0.0" + }, + "peerDependencies": { + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, "node_modules/react-native-progress": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-native-progress/-/react-native-progress-5.0.1.tgz", @@ -9776,6 +9820,15 @@ "react-native": "*" } }, + "node_modules/react-native-sound": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", + "integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.8.0" + } + }, "node_modules/react-native-sse": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-sse/-/react-native-sse-1.2.1.tgz", @@ -11563,6 +11616,12 @@ "react": ">=16.8" } }, + "node_modules/utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index fd3a2bf9088f4150929498d11e1f5854e6b360de..26120e57f9d044773e714c74bb5e4099ee821076 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "react-native-vector-icons": "^10.2.0", "react-query": "^3.39.3", "rxjs": "^7.8.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "expo-av": "~15.0.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/screens/PlayingQuiz/PlayingQuizBody.tsx b/screens/PlayingQuiz/PlayingQuizBody.tsx index 619e3b7c2e91e2879e7c388106b136af79b794c5..a8613184a7d93b4e483c65cf4b2ba722f4f29655 100644 --- a/screens/PlayingQuiz/PlayingQuizBody.tsx +++ b/screens/PlayingQuiz/PlayingQuizBody.tsx @@ -11,7 +11,6 @@ import {Answer} from "../../models/Answer"; import {Quiz} from "../../models/Quiz"; import AnswerButton from "../../components/buttons/AnswerButton"; import {QuizInformations} from "../../models/QuizInformations"; -import AnswerButtonImage from "../../components/buttons/AnswerButtonImage"; interface Props { quizInformations?: QuizInformations; @@ -71,6 +70,7 @@ export default function PlayingQuizBody({ quizInformations, runId, actualQuestio const [quizState, setQuizState] = useState(QuizState.ANSWERING); const [correctAnswerId, setCorrectAnswerId] = useState<number | null>(null); const [isLastQuestion, setIsLastQuestion] = useState(false); + const [forceEnd, setForceEnd] = useState(false); const {answerQuestion} = useQuizService(); const getCorrectAnswer = (answers: Answer[]): Answer => { @@ -81,6 +81,7 @@ export default function PlayingQuizBody({ quizInformations, runId, actualQuestio const onValidation = async () => { if(selectedAnswerId === null) return; setQuizState(QuizState.LOADING); + setForceEnd(!forceEnd); const answerId = actualQuestion.answers.find((answer) => answer.id === selectedAnswerId)?.id; if(!answerId || !runId) return; const answereFetched = await answerQuestion(runId, actualQuestion.id, answerId); @@ -101,7 +102,8 @@ export default function PlayingQuizBody({ quizInformations, runId, actualQuestio } }; - const onContinue = () => { + const onContinue = async () => { + await setForceEnd(!forceEnd); if(isLastQuestion) { navigation.reset({ index: 0, @@ -136,23 +138,15 @@ export default function PlayingQuizBody({ quizInformations, runId, actualQuestio contentContainerStyle={styles.buttonListContentContainer} > {actualQuestion.answers.map((answer, index) => ( - actualQuestion.type === "image" ? - <AnswerButtonImage - key={index} - url={answer.text} - handleButtonPressed={() => onAnsweredButtonClicked(answer.id)} - buttonStyle={getStyleOfAnswerButton(answer.id, selectedAnswerId, correctAnswerId, quizState)} - /> - : - <AnswerButton - key={index} - text={answer.text} - handleButtonPressed={() => onAnsweredButtonClicked(answer.id)} - buttonStyle={getStyleOfAnswerButton(answer.id, selectedAnswerId, correctAnswerId, quizState)} - buttonText={getStyleOfAnswerButtonText(answer.id, selectedAnswerId, correctAnswerId, quizState)} - /> + <AnswerButton + key={index} + text={answer.text} + handleButtonPressed={() => onAnsweredButtonClicked(answer.id)} + buttonStyle={getStyleOfAnswerButton(answer.id, selectedAnswerId, correctAnswerId, quizState)} + buttonText={getStyleOfAnswerButtonText(answer.id, selectedAnswerId, correctAnswerId, quizState)} + forceEnd={forceEnd} + /> ))} - {/* </View> */} </ScrollView> <View style={styles.playButtonContainer}> <BlueButton onPress={() => quizState === QuizState.ANSWERING ? onValidation() : onContinue()} text={quizState === QuizState.ANSWERING ? "CONFIRM" : "CONTINUE"} buttonStyle={{borderRadius: 50}} isDisabled={false}/>