feat: implementa Modo Foco para grava����o de leitura - Adiciona ativa����o/desativa����o autom��tica, integra AudioRecorder, interface adaptativa e atualiza CHANGELOG

This commit is contained in:
Lucas Santana 2025-01-26 12:26:21 -03:00
parent e5204e0430
commit dadcb048bb
4 changed files with 299 additions and 41 deletions

View File

@ -78,6 +78,23 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
## [1.1.0] - 2024-03-21 ## [1.1.0] - 2024-03-21
### Adicionado
- Novo recurso "Modo Foco" para melhorar a experiência de leitura
- Ativação automática ao iniciar gravação
- Desativação automática ao parar gravação
- Interface adaptativa com foco no texto
- Controles de acessibilidade (tamanho da fonte, espaçamento)
- Destaque automático de palavras durante a leitura
### Técnico
- Integração entre componentes `AudioRecorder` e `StoryPage` para gerenciamento do Modo Foco
- Adição de novos props no componente `AudioRecorder`:
- `onFocusModeToggle`
- `focusModeActive`
- `onRecordingStart`
- `onRecordingStop`
- Otimização de código com remoção de variáveis não utilizadas
### Modificado ### Modificado
- Atualizado o componente `AudioRecorder` para incluir tipagem correta e melhor gerenciamento de estado - Atualizado o componente `AudioRecorder` para incluir tipagem correta e melhor gerenciamento de estado
- Corrigido o gerenciamento de gravações no `StoryPage` com inicialização adequada de métricas - Corrigido o gerenciamento de gravações no `StoryPage` com inicialização adequada de métricas
@ -95,23 +112,24 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Feedback visual durante o processamento do áudio - Feedback visual durante o processamento do áudio
- Inicialização de métricas zeradas para novas gravações - Inicialização de métricas zeradas para novas gravações
## [1.2.0] - 2024-05-22 ## [1.2.0] - 2024-03-21
### Adicionado ### Adicionado
- Criação de histórias por comando de voz - Novo Modo Foco para leitura e gravação
- Componente `VoiceCommandButton` para gravação de áudio - Estilos específicos para o Modo Foco
- Hook `useSpeechRecognition` para reconhecimento de voz - Timer de gravação no Modo Foco
- Sistema de validação de conteúdo de áudio - Transições suaves entre modos
- Integração com geração de histórias por IA - Controles flutuantes durante o Modo Foco
- Documentação de recursos de voz
### Modificado ### Modificado
- Fluxo de criação de histórias para suportar entrada por voz - Componente AudioRecorder atualizado para suportar Modo Foco
- Interface do gerador de histórias com novo modo de entrada - Interface do StoryPage reorganizada para Modo Foco
- Melhorias na experiência do usuário para gravação - Comportamento de gravação integrado com Modo Foco
- Melhorias na experiência do usuário durante a leitura
### Técnico ### Técnico
- Implementação de reconhecimento de voz com Web Speech API - Novo arquivo CSS para estilos do Modo Foco
- Sistema de validação de conteúdo sensível em transcrições - Interface FocusMode para gerenciamento de estado
- Otimização do processamento de comandos de voz - Callbacks de início e fim de gravação
- Melhorias na segurança do processamento de áudio - Sistema de transição entre modos normal e foco
- Otimização de performance para transições suaves

View File

@ -2,15 +2,27 @@ import React, { useState, useRef } from 'react';
import { Mic, Square, Loader, Play, Upload } from 'lucide-react'; import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useStudentTracking } from '../../hooks/useStudentTracking'; import { cn } from '../../lib/utils';
interface AudioRecorderProps { interface AudioRecorderProps {
storyId: string; storyId: string;
studentId: string; studentId: string;
onAudioUploaded: (audioUrl: string) => void; onAudioUploaded: (audioUrl: string) => void;
onRecordingStart?: () => void;
onRecordingStop?: () => void;
focusModeActive?: boolean;
onFocusModeToggle?: () => void;
} }
export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioRecorderProps) { export function AudioRecorder({
storyId,
studentId,
onAudioUploaded,
onRecordingStart,
onRecordingStop,
focusModeActive = false,
onFocusModeToggle
}: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null); const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@ -18,11 +30,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const { trackAudioRecorded } = useStudentTracking();
const startTime = React.useRef<number | null>(null); const startTime = React.useRef<number | null>(null);
const startRecording = async () => { const startRecording = async () => {
try { try {
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream); mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = []; chunksRef.current = [];
@ -34,12 +49,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
mediaRecorderRef.current.onstop = () => { mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob); setAudioBlob(audioBlob);
onRecordingStop?.();
}; };
mediaRecorderRef.current.start(); mediaRecorderRef.current.start();
startTime.current = Date.now(); startTime.current = Date.now();
setIsRecording(true); setIsRecording(true);
setError(null); setError(null);
onRecordingStart?.();
} catch (err) { } catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.'); setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err); console.error('Erro ao iniciar gravação:', err);
@ -51,8 +68,12 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
mediaRecorderRef.current.stop(); mediaRecorderRef.current.stop();
setIsRecording(false); setIsRecording(false);
// Parar todas as tracks do stream
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
// Desativar modo foco ao parar a gravação
if (focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
} }
}; };
@ -112,7 +133,7 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
.getPublicUrl(filePath); .getPublicUrl(filePath);
// 3. Criar o registro com a URL do áudio // 3. Criar o registro com a URL do áudio
const { data: recordData, error: recordError } = await supabase const { error: recordError } = await supabase
.from('story_recordings') .from('story_recordings')
.insert({ .insert({
id: fileId, id: fileId,
@ -152,15 +173,23 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
}; };
return ( return (
<div className="p-4 bg-white rounded-lg shadow"> <div className={cn(
"p-4 bg-white rounded-lg shadow",
focusModeActive && "bg-purple-50"
)}>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
{!isRecording && !audioBlob && ( {!isRecording && !audioBlob && (
<button <button
onClick={startRecording} onClick={startRecording}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition" className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg transition",
focusModeActive
? "bg-purple-600 text-white hover:bg-purple-700"
: "bg-red-600 text-white hover:bg-red-700"
)}
> >
<Mic className="w-5 h-5" /> <Mic className="w-5 h-5" />
Iniciar Gravação {focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
</button> </button>
)} )}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Share2, ChevronDown, ChevronUp, Loader2, Download, RefreshCw, Trash2, Type } from 'lucide-react'; import { ArrowLeft, ArrowRight, Share2, ChevronDown, ChevronUp, Loader2, Download, RefreshCw, Trash2, Type, Eye, EyeOff } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder'; import { AudioRecorder } from '../../components/story/AudioRecorder';
@ -36,6 +36,17 @@ interface StoryRecording {
transcription: string; transcription: string;
} }
interface FocusMode {
isActive: boolean;
isRecording: boolean;
originalState: {
isUpperCase: boolean;
isHighlighting: boolean;
fontSize: number;
imageVisible: boolean;
};
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) { function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const [isAudioSupported, setIsAudioSupported] = React.useState(true); const [isAudioSupported, setIsAudioSupported] = React.useState(true);
@ -384,13 +395,23 @@ export function StoryPage() {
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id); const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables(); const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables();
const [fontSize, setFontSize] = useState(18); const [fontSize, setFontSize] = useState(18);
const [readingSpeed, setReadingSpeed] = useState(120); // 120 palavras por minuto const [readingSpeed, setReadingSpeed] = useState(60); // 60 palavras por minuto
const [letterSpacing, setLetterSpacing] = useState(0); const [letterSpacing, setLetterSpacing] = useState(0);
const [wordSpacing, setWordSpacing] = useState(0); const [wordSpacing, setWordSpacing] = useState(0);
const [lineHeight, setLineHeight] = useState(1.5); const [lineHeight, setLineHeight] = useState(1.5);
const [isHighlighting, setIsHighlighting] = useState(false); const [isHighlighting, setIsHighlighting] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(-1); const [currentWordIndex, setCurrentWordIndex] = useState(-1);
const highlightInterval = React.useRef<number | null>(null); const highlightInterval = React.useRef<number | null>(null);
const [focusMode, setFocusMode] = useState<FocusMode>({
isActive: false,
isRecording: false,
originalState: {
isUpperCase: false,
isHighlighting: false,
fontSize: 18,
imageVisible: true
}
});
// Função para dividir o texto em palavras // Função para dividir o texto em palavras
const getWords = (text: string) => text.split(/\s+/); const getWords = (text: string) => text.split(/\s+/);
@ -642,6 +663,55 @@ export function StoryPage() {
} }
}; };
const handleFocusModeToggle = () => {
if (!focusMode.isActive) {
// Salvar estado atual antes de ativar o modo foco
setFocusMode(prev => ({
...prev,
isActive: true,
originalState: {
isUpperCase,
isHighlighting,
fontSize,
imageVisible: true
}
}));
// Aplicar configurações do modo foco
toggleUppercase();
setIsHighlighting(true);
setFontSize(24);
} else {
// Restaurar estado original
if (isUpperCase !== focusMode.originalState.isUpperCase) {
toggleUppercase();
}
setIsHighlighting(focusMode.originalState.isHighlighting);
setFontSize(focusMode.originalState.fontSize);
// Resetar modo foco
setFocusMode(prev => ({
...prev,
isActive: false,
isRecording: false
}));
}
};
const handleRecordingStart = () => {
setFocusMode(prev => ({
...prev,
isRecording: true
}));
};
const handleRecordingStop = () => {
setFocusMode(prev => ({
...prev,
isRecording: false
}));
};
if (loading) { if (loading) {
return ( return (
<div className="animate-pulse"> <div className="animate-pulse">
@ -678,6 +748,27 @@ export function StoryPage() {
</button> </button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button
onClick={handleFocusModeToggle}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900 border border-gray-200',
focusMode.isActive && 'bg-purple-50 text-purple-600 border-purple-200'
)}
>
{focusMode.isActive ? (
<>
<Eye className="h-4 w-4" />
<span className="text-sm font-medium">Sair do Modo Foco</span>
</>
) : (
<>
<EyeOff className="h-4 w-4" />
<span className="text-sm font-medium">Modo Foco</span>
</>
)}
</button>
<TextCaseToggle <TextCaseToggle
isUpperCase={isUpperCase} isUpperCase={isUpperCase}
onToggle={toggleUppercase} onToggle={toggleUppercase}
@ -703,9 +794,16 @@ export function StoryPage() {
</div> </div>
{/* Conteúdo Principal */} {/* Conteúdo Principal */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6"> <div className={cn(
"bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6",
"transition-all duration-300",
focusMode.isActive && "bg-gray-50"
)}>
{/* 1. Controles de Texto */} {/* 1. Controles de Texto */}
<div className="border-b border-gray-200 pb-6"> <div className={cn(
"border-b border-gray-200 pb-6",
focusMode.isActive && "opacity-50 pointer-events-none"
)}>
<TextControls <TextControls
isUpperCase={isUpperCase} isUpperCase={isUpperCase}
onToggleUpperCase={toggleUppercase} onToggleUpperCase={toggleUppercase}
@ -774,25 +872,29 @@ export function StoryPage() {
</div> </div>
</div> </div>
{/* 5. Imagem da História */} {/* 5. Imagem da História - Agora com controle de visibilidade */}
<div className="border-b border-gray-200 pb-6"> {!focusMode.isActive && (
{story?.content?.pages?.[currentPage]?.image ? ( <div className="border-b border-gray-200 pb-6">
<ImageWithLoading {story?.content?.pages?.[currentPage]?.image ? (
src={story.content.pages[currentPage].image} <ImageWithLoading
alt={`Ilustração da página ${currentPage + 1}`} src={story.content.pages[currentPage].image}
className="w-full rounded-lg" alt={`Ilustração da página ${currentPage + 1}`}
/> className="w-full rounded-lg"
) : ( />
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center"> ) : (
<p className="text-gray-500">Sem imagem para esta página</p> <div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
</div> <p className="text-gray-500">Sem imagem para esta página</p>
)} </div>
</div> )}
</div>
)}
{/* 6. Controle de Gravação */} {/* 6. Controle de Gravação */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Gravação de Áudio</h2> <h2 className="text-lg font-semibold text-gray-900">
{focusMode.isActive ? "Modo Foco Ativado" : "Gravação de Áudio"}
</h2>
<AudioRecorder <AudioRecorder
storyId={story.id} storyId={story.id}
studentId={session?.user?.id || ''} studentId={session?.user?.id || ''}
@ -816,7 +918,16 @@ export function StoryPage() {
transcription: '' transcription: ''
}; };
setRecordings(prev => [newRecording, ...prev]); setRecordings(prev => [newRecording, ...prev]);
// Desativar modo foco após finalizar gravação
if (focusMode.isActive) {
handleFocusModeToggle();
}
}} }}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
focusModeActive={focusMode.isActive}
onFocusModeToggle={handleFocusModeToggle}
/> />
</div> </div>
</div> </div>

100
src/styles/focus-mode.css Normal file
View File

@ -0,0 +1,100 @@
/* Estilos para o Modo Foco */
.focus-mode-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
transition: all 0.3s ease-in-out;
}
.focus-mode-active {
background-color: #f8f9fc;
min-height: 100vh;
}
.focus-mode-text {
font-size: 24px;
line-height: 1.8;
letter-spacing: 0.5px;
word-spacing: 2px;
text-align: center;
padding: 2rem;
background-color: white;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.focus-mode-controls {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
padding: 1rem;
background-color: white;
border-radius: 9999px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 50;
}
.focus-mode-highlight {
background-color: #fef3c7;
border-radius: 0.25rem;
padding: 0 0.25rem;
transition: background-color 0.2s ease-in-out;
}
.focus-mode-timer {
position: fixed;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
background-color: white;
border-radius: 9999px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
font-size: 1.125rem;
font-weight: 500;
color: #4b5563;
z-index: 50;
}
/* Animações */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.focus-mode-transition {
animation: fadeIn 0.3s ease-out;
}
/* Responsividade */
@media (max-width: 640px) {
.focus-mode-container {
padding: 1rem;
}
.focus-mode-text {
font-size: 20px;
padding: 1rem;
}
.focus-mode-controls {
bottom: 1rem;
padding: 0.75rem;
}
.focus-mode-timer {
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
font-size: 1rem;
}
}