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
### 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
- 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
@ -95,23 +112,24 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Feedback visual durante o processamento do áudio
- Inicialização de métricas zeradas para novas gravações
## [1.2.0] - 2024-05-22
## [1.2.0] - 2024-03-21
### Adicionado
- Criação de histórias por comando de voz
- Componente `VoiceCommandButton` para gravação de áudio
- Hook `useSpeechRecognition` para reconhecimento de voz
- Sistema de validação de conteúdo de áudio
- Integração com geração de histórias por IA
- Documentação de recursos de voz
- Novo Modo Foco para leitura e gravação
- Estilos específicos para o Modo Foco
- Timer de gravação no Modo Foco
- Transições suaves entre modos
- Controles flutuantes durante o Modo Foco
### Modificado
- Fluxo de criação de histórias para suportar entrada por voz
- Interface do gerador de histórias com novo modo de entrada
- Melhorias na experiência do usuário para gravação
- Componente AudioRecorder atualizado para suportar Modo Foco
- Interface do StoryPage reorganizada para Modo Foco
- Comportamento de gravação integrado com Modo Foco
- Melhorias na experiência do usuário durante a leitura
### Técnico
- Implementação de reconhecimento de voz com Web Speech API
- Sistema de validação de conteúdo sensível em transcrições
- Otimização do processamento de comandos de voz
- Melhorias na segurança do processamento de áudio
- Novo arquivo CSS para estilos do Modo Foco
- Interface FocusMode para gerenciamento de estado
- Callbacks de início e fim de gravação
- 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 { supabase } from '../../lib/supabase';
import { v4 as uuidv4 } from 'uuid';
import { useStudentTracking } from '../../hooks/useStudentTracking';
import { cn } from '../../lib/utils';
interface AudioRecorderProps {
storyId: string;
studentId: string;
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 [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false);
@ -18,11 +30,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const { trackAudioRecorded } = useStudentTracking();
const startTime = React.useRef<number | null>(null);
const startRecording = async () => {
try {
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
@ -34,12 +49,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
onRecordingStop?.();
};
mediaRecorderRef.current.start();
startTime.current = Date.now();
setIsRecording(true);
setError(null);
onRecordingStart?.();
} catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err);
@ -51,8 +68,12 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
mediaRecorderRef.current.stop();
setIsRecording(false);
// Parar todas as tracks do stream
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);
// 3. Criar o registro com a URL do áudio
const { data: recordData, error: recordError } = await supabase
const { error: recordError } = await supabase
.from('story_recordings')
.insert({
id: fileId,
@ -152,15 +173,23 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
};
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">
{!isRecording && !audioBlob && (
<button
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" />
Iniciar Gravação
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
</button>
)}

View File

@ -1,5 +1,5 @@
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 { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
@ -36,6 +36,17 @@ interface StoryRecording {
transcription: string;
}
interface FocusMode {
isActive: boolean;
isRecording: boolean;
originalState: {
isUpperCase: boolean;
isHighlighting: boolean;
fontSize: number;
imageVisible: boolean;
};
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
@ -384,13 +395,23 @@ export function StoryPage() {
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables();
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 [wordSpacing, setWordSpacing] = useState(0);
const [lineHeight, setLineHeight] = useState(1.5);
const [isHighlighting, setIsHighlighting] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(-1);
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
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) {
return (
<div className="animate-pulse">
@ -678,6 +748,27 @@ export function StoryPage() {
</button>
<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
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
@ -703,9 +794,16 @@ export function StoryPage() {
</div>
{/* 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 */}
<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
isUpperCase={isUpperCase}
onToggleUpperCase={toggleUppercase}
@ -774,25 +872,29 @@ export function StoryPage() {
</div>
</div>
{/* 5. Imagem da História */}
<div className="border-b border-gray-200 pb-6">
{story?.content?.pages?.[currentPage]?.image ? (
<ImageWithLoading
src={story.content.pages[currentPage].image}
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>
)}
</div>
{/* 5. Imagem da História - Agora com controle de visibilidade */}
{!focusMode.isActive && (
<div className="border-b border-gray-200 pb-6">
{story?.content?.pages?.[currentPage]?.image ? (
<ImageWithLoading
src={story.content.pages[currentPage].image}
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>
)}
</div>
)}
{/* 6. Controle de Gravação */}
<div>
<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
storyId={story.id}
studentId={session?.user?.id || ''}
@ -816,7 +918,16 @@ export function StoryPage() {
transcription: ''
};
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>

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;
}
}