@schoolio/player 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -80,6 +80,23 @@ var QuizApiClient = class {
80
80
  `/api/external/question-chat/${questionId}/${childId}`
81
81
  );
82
82
  }
83
+ async getTextToSpeech(text, voice = "nova") {
84
+ const headers = {
85
+ "Content-Type": "application/json"
86
+ };
87
+ if (this.authToken) {
88
+ headers["Authorization"] = `Bearer ${this.authToken}`;
89
+ }
90
+ const response = await fetch(`${this.baseUrl}/api/external/tts`, {
91
+ method: "POST",
92
+ headers,
93
+ body: JSON.stringify({ text, voice })
94
+ });
95
+ if (!response.ok) {
96
+ throw new Error(`TTS request failed: HTTP ${response.status}`);
97
+ }
98
+ return response.blob();
99
+ }
83
100
  };
84
101
 
85
102
  // src/utils.ts
@@ -482,9 +499,13 @@ var panelStyles = {
482
499
  messageRow: {
483
500
  display: "flex"
484
501
  },
502
+ messageContent: {
503
+ display: "flex",
504
+ alignItems: "flex-end",
505
+ gap: "4px",
506
+ maxWidth: "85%"
507
+ },
485
508
  userMessage: {
486
- maxWidth: "85%",
487
- marginLeft: "auto",
488
509
  padding: "10px 14px",
489
510
  borderRadius: "16px 16px 4px 16px",
490
511
  backgroundColor: "#6721b0",
@@ -493,8 +514,6 @@ var panelStyles = {
493
514
  lineHeight: 1.4
494
515
  },
495
516
  assistantMessage: {
496
- maxWidth: "85%",
497
- marginRight: "auto",
498
517
  padding: "10px 14px",
499
518
  borderRadius: "16px 16px 16px 4px",
500
519
  backgroundColor: "#ffffff",
@@ -508,7 +527,8 @@ var panelStyles = {
508
527
  borderTop: "1px solid #e2e8f0",
509
528
  backgroundColor: "#ffffff",
510
529
  display: "flex",
511
- gap: "8px"
530
+ gap: "8px",
531
+ alignItems: "center"
512
532
  },
513
533
  input: {
514
534
  flex: 1,
@@ -518,13 +538,11 @@ var panelStyles = {
518
538
  fontSize: "14px",
519
539
  outline: "none"
520
540
  },
521
- sendButton: {
541
+ buttonBase: {
522
542
  width: "40px",
523
543
  height: "40px",
524
544
  borderRadius: "50%",
525
545
  border: "none",
526
- backgroundColor: "#6721b0",
527
- color: "#ffffff",
528
546
  cursor: "pointer",
529
547
  display: "flex",
530
548
  alignItems: "center",
@@ -532,10 +550,47 @@ var panelStyles = {
532
550
  fontSize: "16px",
533
551
  transition: "all 0.2s ease"
534
552
  },
553
+ sendButton: {
554
+ backgroundColor: "#6721b0",
555
+ color: "#ffffff"
556
+ },
535
557
  sendButtonDisabled: {
536
558
  backgroundColor: "#d1d5db",
537
559
  cursor: "not-allowed"
538
560
  },
561
+ micButton: {
562
+ backgroundColor: "#ffffff",
563
+ border: "1px solid #e2e8f0",
564
+ color: "#374151"
565
+ },
566
+ micButtonActive: {
567
+ backgroundColor: "#ef4444",
568
+ color: "#ffffff",
569
+ border: "none"
570
+ },
571
+ speakButton: {
572
+ width: "28px",
573
+ height: "28px",
574
+ borderRadius: "50%",
575
+ border: "none",
576
+ backgroundColor: "transparent",
577
+ color: "#9ca3af",
578
+ cursor: "pointer",
579
+ display: "flex",
580
+ alignItems: "center",
581
+ justifyContent: "center",
582
+ transition: "all 0.2s ease",
583
+ flexShrink: 0
584
+ },
585
+ speakButtonReady: {
586
+ color: "#22c55e"
587
+ },
588
+ speakButtonPlaying: {
589
+ color: "#6721b0"
590
+ },
591
+ speakButtonLoading: {
592
+ color: "#f97316"
593
+ },
539
594
  loadingDots: {
540
595
  display: "flex",
541
596
  alignItems: "center",
@@ -573,6 +628,33 @@ var STARTER_PROMPTS = [
573
628
  { id: "word_help", label: "I don't understand a word", message: "I don't understand a word in this question. Can you help?" },
574
629
  { id: "explain_again", label: "Explain this again", message: "Can you explain this question to me again in a different way?" }
575
630
  ];
631
+ var MicIcon = () => /* @__PURE__ */ jsxs2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
632
+ /* @__PURE__ */ jsx2("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }),
633
+ /* @__PURE__ */ jsx2("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }),
634
+ /* @__PURE__ */ jsx2("line", { x1: "12", x2: "12", y1: "19", y2: "22" })
635
+ ] });
636
+ var MicOffIcon = () => /* @__PURE__ */ jsxs2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
637
+ /* @__PURE__ */ jsx2("line", { x1: "2", x2: "22", y1: "2", y2: "22" }),
638
+ /* @__PURE__ */ jsx2("path", { d: "M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" }),
639
+ /* @__PURE__ */ jsx2("path", { d: "M5 10v2a7 7 0 0 0 12 5" }),
640
+ /* @__PURE__ */ jsx2("path", { d: "M15 9.34V5a3 3 0 0 0-5.68-1.33" }),
641
+ /* @__PURE__ */ jsx2("path", { d: "M9 9v3a3 3 0 0 0 5.12 2.12" }),
642
+ /* @__PURE__ */ jsx2("line", { x1: "12", x2: "12", y1: "19", y2: "22" })
643
+ ] });
644
+ var VolumeIcon2 = () => /* @__PURE__ */ jsxs2("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
645
+ /* @__PURE__ */ jsx2("polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }),
646
+ /* @__PURE__ */ jsx2("path", { d: "M15.54 8.46a5 5 0 0 1 0 7.07" }),
647
+ /* @__PURE__ */ jsx2("path", { d: "M19.07 4.93a10 10 0 0 1 0 14.14" })
648
+ ] });
649
+ var SendIcon = () => /* @__PURE__ */ jsxs2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
650
+ /* @__PURE__ */ jsx2("line", { x1: "22", y1: "2", x2: "11", y2: "13" }),
651
+ /* @__PURE__ */ jsx2("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })
652
+ ] });
653
+ var HelpIcon = () => /* @__PURE__ */ jsxs2("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "#6721b0", strokeWidth: "2", children: [
654
+ /* @__PURE__ */ jsx2("circle", { cx: "12", cy: "12", r: "10" }),
655
+ /* @__PURE__ */ jsx2("path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }),
656
+ /* @__PURE__ */ jsx2("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" })
657
+ ] });
576
658
  function QuestionChatPanel({
577
659
  apiClient,
578
660
  question,
@@ -587,13 +669,121 @@ function QuestionChatPanel({
587
669
  const [isLoading, setIsLoading] = useState2(false);
588
670
  const [chatId, setChatId] = useState2(null);
589
671
  const [hoveredButton, setHoveredButton] = useState2(null);
672
+ const [isListening, setIsListening] = useState2(false);
673
+ const [speakingIndex, setSpeakingIndex] = useState2(null);
674
+ const [audioReadyMap, setAudioReadyMap] = useState2(/* @__PURE__ */ new Map());
590
675
  const messagesContainerRef = useRef2(null);
591
676
  const messagesEndRef = useRef2(null);
677
+ const recognitionRef = useRef2(null);
678
+ const audioRef = useRef2(null);
679
+ const audioCacheRef = useRef2(/* @__PURE__ */ new Map());
680
+ const isSpeechSupported = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
592
681
  const scrollToBottom = useCallback(() => {
593
682
  if (messagesContainerRef.current) {
594
683
  messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
595
684
  }
596
685
  }, []);
686
+ const preCacheAudio = useCallback(async (text) => {
687
+ if (audioCacheRef.current.has(text)) {
688
+ setAudioReadyMap((prev) => new Map(prev).set(text, true));
689
+ return;
690
+ }
691
+ setAudioReadyMap((prev) => new Map(prev).set(text, false));
692
+ try {
693
+ const audioBlob = await apiClient.getTextToSpeech(text, "nova");
694
+ audioCacheRef.current.set(text, audioBlob);
695
+ setAudioReadyMap((prev) => new Map(prev).set(text, true));
696
+ } catch (error) {
697
+ console.error("Pre-cache TTS error:", error);
698
+ }
699
+ }, [apiClient]);
700
+ const startListening = useCallback(() => {
701
+ const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
702
+ if (!SpeechRecognitionAPI) {
703
+ console.log("Speech recognition not supported");
704
+ return;
705
+ }
706
+ const recognition = new SpeechRecognitionAPI();
707
+ recognition.continuous = true;
708
+ recognition.interimResults = true;
709
+ recognition.lang = "en-US";
710
+ recognition.onstart = () => {
711
+ console.log("Speech recognition started");
712
+ setIsListening(true);
713
+ };
714
+ recognition.onresult = (event) => {
715
+ let finalTranscript = "";
716
+ for (let i = 0; i < Object.keys(event.results).length; i++) {
717
+ const result = event.results[i];
718
+ if (result && result[0]) {
719
+ finalTranscript += result[0].transcript;
720
+ }
721
+ }
722
+ if (finalTranscript) {
723
+ setInputValue(finalTranscript);
724
+ }
725
+ };
726
+ recognition.onerror = (event) => {
727
+ console.log("Speech recognition error:", event.error);
728
+ if (event.error === "not-allowed") {
729
+ alert("Microphone access was denied. Please allow microphone access and try again.");
730
+ }
731
+ setIsListening(false);
732
+ };
733
+ recognition.onend = () => {
734
+ console.log("Speech recognition ended");
735
+ setIsListening(false);
736
+ };
737
+ recognitionRef.current = recognition;
738
+ try {
739
+ recognition.start();
740
+ } catch (e) {
741
+ console.log("Failed to start recognition:", e);
742
+ setIsListening(false);
743
+ }
744
+ }, []);
745
+ const stopListening = useCallback(() => {
746
+ if (recognitionRef.current) {
747
+ recognitionRef.current.stop();
748
+ setIsListening(false);
749
+ }
750
+ }, []);
751
+ const speakMessage = useCallback(async (text, index) => {
752
+ if (audioRef.current) {
753
+ audioRef.current.pause();
754
+ audioRef.current = null;
755
+ }
756
+ if (speakingIndex === index) {
757
+ setSpeakingIndex(null);
758
+ return;
759
+ }
760
+ setSpeakingIndex(index);
761
+ try {
762
+ let audioBlob;
763
+ const cachedBlob = audioCacheRef.current.get(text);
764
+ if (cachedBlob) {
765
+ audioBlob = cachedBlob;
766
+ } else {
767
+ audioBlob = await apiClient.getTextToSpeech(text, "nova");
768
+ audioCacheRef.current.set(text, audioBlob);
769
+ }
770
+ const audioUrl = URL.createObjectURL(audioBlob);
771
+ const audio = new Audio(audioUrl);
772
+ audioRef.current = audio;
773
+ audio.onended = () => {
774
+ setSpeakingIndex(null);
775
+ URL.revokeObjectURL(audioUrl);
776
+ };
777
+ audio.onerror = () => {
778
+ setSpeakingIndex(null);
779
+ URL.revokeObjectURL(audioUrl);
780
+ };
781
+ await audio.play();
782
+ } catch (error) {
783
+ console.error("TTS error:", error);
784
+ setSpeakingIndex(null);
785
+ }
786
+ }, [speakingIndex, apiClient]);
597
787
  useEffect2(() => {
598
788
  scrollToBottom();
599
789
  }, [messages, scrollToBottom]);
@@ -607,13 +797,14 @@ function QuestionChatPanel({
607
797
  if (history.chatId && history.messages.length > 0) {
608
798
  setChatId(history.chatId);
609
799
  setMessages(history.messages);
800
+ history.messages.filter((m) => m.role === "assistant").forEach((m) => preCacheAudio(m.content));
610
801
  }
611
802
  } catch (err) {
612
803
  console.error("Failed to load chat history:", err);
613
804
  }
614
805
  };
615
806
  loadHistory();
616
- }, [question.id, childId, apiClient]);
807
+ }, [question.id, childId, apiClient, preCacheAudio]);
617
808
  const initializeChat = async () => {
618
809
  if (chatId) return chatId;
619
810
  try {
@@ -635,6 +826,9 @@ function QuestionChatPanel({
635
826
  };
636
827
  const sendMessage = async (messageText) => {
637
828
  if (!messageText.trim() || isLoading) return;
829
+ if (isListening) {
830
+ stopListening();
831
+ }
638
832
  setIsLoading(true);
639
833
  const userMsg = {
640
834
  role: "user",
@@ -654,15 +848,23 @@ function QuestionChatPanel({
654
848
  questionContext: question,
655
849
  childId
656
850
  });
657
- setMessages((prev) => [...prev, response.assistantMessage]);
851
+ const assistantMessage = response.assistantMessage || {
852
+ role: "assistant",
853
+ content: "I'm here to help!",
854
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
855
+ };
856
+ setMessages((prev) => [...prev, assistantMessage]);
857
+ preCacheAudio(assistantMessage.content);
658
858
  } catch (err) {
659
859
  console.error("Failed to send message:", err);
860
+ const content = "Sorry, I'm having trouble right now. Please try again!";
660
861
  const errorMsg = {
661
862
  role: "assistant",
662
- content: "Sorry, I'm having trouble right now. Please try again!",
863
+ content,
663
864
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
664
865
  };
665
866
  setMessages((prev) => [...prev, errorMsg]);
867
+ preCacheAudio(content);
666
868
  } finally {
667
869
  setIsLoading(false);
668
870
  }
@@ -679,14 +881,14 @@ function QuestionChatPanel({
679
881
  0%, 60%, 100% { transform: translateY(0); }
680
882
  30% { transform: translateY(-4px); }
681
883
  }
884
+ @keyframes pulse {
885
+ 0%, 100% { opacity: 1; }
886
+ 50% { opacity: 0.5; }
887
+ }
682
888
  ` }),
683
889
  /* @__PURE__ */ jsx2("div", { style: panelStyles.header, children: /* @__PURE__ */ jsx2("span", { children: "Need Help?" }) }),
684
890
  /* @__PURE__ */ jsx2("div", { ref: messagesContainerRef, style: panelStyles.messagesContainer, children: messages.length === 0 ? /* @__PURE__ */ jsxs2("div", { style: panelStyles.emptyState, children: [
685
- /* @__PURE__ */ jsx2("div", { style: panelStyles.helperIcon, children: /* @__PURE__ */ jsxs2("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "#6721b0", strokeWidth: "2", children: [
686
- /* @__PURE__ */ jsx2("circle", { cx: "12", cy: "12", r: "10" }),
687
- /* @__PURE__ */ jsx2("path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }),
688
- /* @__PURE__ */ jsx2("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" })
689
- ] }) }),
891
+ /* @__PURE__ */ jsx2("div", { style: panelStyles.helperIcon, children: /* @__PURE__ */ jsx2(HelpIcon, {}) }),
690
892
  /* @__PURE__ */ jsx2("div", { style: { fontSize: "14px", fontWeight: "500", marginBottom: "8px" }, children: "Hi! I'm your question helper" }),
691
893
  /* @__PURE__ */ jsx2("div", { style: { fontSize: "13px", color: "#9ca3af" }, children: "Ask me if you need help understanding this question" }),
692
894
  /* @__PURE__ */ jsx2("div", { style: { ...panelStyles.starterPrompts, marginTop: "16px" }, children: STARTER_PROMPTS.map((prompt) => /* @__PURE__ */ jsx2(
@@ -713,7 +915,26 @@ function QuestionChatPanel({
713
915
  ...panelStyles.messageRow,
714
916
  justifyContent: msg.role === "user" ? "flex-end" : "flex-start"
715
917
  },
716
- children: /* @__PURE__ */ jsx2("div", { style: msg.role === "user" ? panelStyles.userMessage : panelStyles.assistantMessage, children: msg.content })
918
+ children: /* @__PURE__ */ jsxs2("div", { style: {
919
+ ...panelStyles.messageContent,
920
+ flexDirection: msg.role === "user" ? "row-reverse" : "row"
921
+ }, children: [
922
+ /* @__PURE__ */ jsx2("div", { style: msg.role === "user" ? panelStyles.userMessage : panelStyles.assistantMessage, children: msg.content }),
923
+ msg.role === "assistant" && /* @__PURE__ */ jsx2(
924
+ "button",
925
+ {
926
+ style: {
927
+ ...panelStyles.speakButton,
928
+ ...speakingIndex === idx ? panelStyles.speakButtonPlaying : audioReadyMap.get(msg.content) === true ? panelStyles.speakButtonReady : audioReadyMap.get(msg.content) === false ? panelStyles.speakButtonLoading : {},
929
+ ...audioReadyMap.get(msg.content) === false ? { animation: "pulse 1.5s ease-in-out infinite" } : {}
930
+ },
931
+ onClick: () => speakMessage(msg.content, idx),
932
+ "data-testid": `button-speak-${idx}`,
933
+ title: "Listen to this message",
934
+ children: /* @__PURE__ */ jsx2(VolumeIcon2, {})
935
+ }
936
+ )
937
+ ] })
717
938
  },
718
939
  idx
719
940
  )),
@@ -732,26 +953,38 @@ function QuestionChatPanel({
732
953
  value: inputValue,
733
954
  onChange: (e) => setInputValue(e.target.value),
734
955
  onKeyPress: handleKeyPress,
735
- placeholder: "Ask about this question...",
956
+ placeholder: isListening ? "Listening..." : "Ask about this question...",
736
957
  style: panelStyles.input,
737
- disabled: isLoading,
958
+ disabled: isLoading || isListening,
738
959
  "data-testid": "input-chat-message"
739
960
  }
740
961
  ),
962
+ isSpeechSupported && /* @__PURE__ */ jsx2(
963
+ "button",
964
+ {
965
+ onClick: isListening ? stopListening : startListening,
966
+ disabled: isLoading,
967
+ style: {
968
+ ...panelStyles.buttonBase,
969
+ ...isListening ? panelStyles.micButtonActive : panelStyles.micButton
970
+ },
971
+ "data-testid": "button-voice-input",
972
+ title: isListening ? "Stop listening" : "Speak your question",
973
+ children: isListening ? /* @__PURE__ */ jsx2(MicOffIcon, {}) : /* @__PURE__ */ jsx2(MicIcon, {})
974
+ }
975
+ ),
741
976
  /* @__PURE__ */ jsx2(
742
977
  "button",
743
978
  {
744
979
  onClick: () => sendMessage(inputValue),
745
980
  disabled: isLoading || !inputValue.trim(),
746
981
  style: {
982
+ ...panelStyles.buttonBase,
747
983
  ...panelStyles.sendButton,
748
984
  ...isLoading || !inputValue.trim() ? panelStyles.sendButtonDisabled : {}
749
985
  },
750
986
  "data-testid": "button-send-chat",
751
- children: /* @__PURE__ */ jsxs2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
752
- /* @__PURE__ */ jsx2("line", { x1: "22", y1: "2", x2: "11", y2: "13" }),
753
- /* @__PURE__ */ jsx2("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })
754
- ] })
987
+ children: /* @__PURE__ */ jsx2(SendIcon, {})
755
988
  }
756
989
  )
757
990
  ] })
@@ -1741,7 +1974,7 @@ function QuizPlayer({
1741
1974
  /* @__PURE__ */ jsxs3("div", { style: defaultStyles.quizContent, children: [
1742
1975
  /* @__PURE__ */ jsxs3("div", { style: { ...defaultStyles.question, position: "relative", paddingBottom: "40px" }, children: [
1743
1976
  /* @__PURE__ */ jsx3("div", { style: defaultStyles.questionText, children: /* @__PURE__ */ jsx3(TextToSpeech, { text: currentQuestion.question, inline: true, size: "md" }) }),
1744
- isExtraQuestion && !showFeedback && /* @__PURE__ */ jsxs3(
1977
+ isExtraQuestion && /* @__PURE__ */ jsxs3(
1745
1978
  "button",
1746
1979
  {
1747
1980
  onClick: () => setShowSkipModal(true),
@@ -1782,7 +2015,7 @@ function QuizPlayer({
1782
2015
  ]
1783
2016
  }
1784
2017
  ),
1785
- !isExtraQuestion && !showFeedback && /* @__PURE__ */ jsxs3(
2018
+ !isExtraQuestion && /* @__PURE__ */ jsxs3(
1786
2019
  "button",
1787
2020
  {
1788
2021
  onClick: () => setShowReportModal(true),