Add vibey onboarding, queue controls, and ambient reader modes
This commit is contained in:
346
App.tsx
346
App.tsx
@@ -1,7 +1,7 @@
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2 } from 'lucide-react';
|
||||
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2, Sparkles, Shuffle, History as HistoryIcon, GripVertical, Waves } from 'lucide-react';
|
||||
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
|
||||
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
|
||||
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
|
||||
@@ -16,8 +16,11 @@ export default function App() {
|
||||
// -- State --
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [queue, setQueue] = useState<Article[]>([]);
|
||||
const [history, setHistory] = useState<Article[]>([]);
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [activeList, setActiveList] = useState<'queue' | 'history'>('queue');
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
const [playerState, setPlayerState] = useState<PlayerState>({
|
||||
isPlaying: false,
|
||||
@@ -31,9 +34,19 @@ export default function App() {
|
||||
fontSize: 'lg',
|
||||
lineHeight: 'relaxed',
|
||||
fontFamily: 'serif',
|
||||
autoScroll: true
|
||||
autoScroll: true,
|
||||
readingTone: 'clean',
|
||||
pageWidth: 'standard',
|
||||
zenMode: false
|
||||
});
|
||||
|
||||
const starterPicks = [
|
||||
{ label: 'Tech trends', url: 'https://news.ycombinator.com' },
|
||||
{ label: 'Global headlines', url: 'https://www.reuters.com/world/' },
|
||||
{ label: 'Science daily', url: 'https://www.sciencedaily.com/news/top/science/' },
|
||||
{ label: 'Longform feature', url: 'https://www.theguardian.com/world/interactive/2024/jan/01' }
|
||||
];
|
||||
|
||||
// -- Refs --
|
||||
const audioRef = useRef<HTMLAudioElement>(new Audio());
|
||||
const processingRef = useRef<Set<string>>(new Set());
|
||||
@@ -148,33 +161,40 @@ export default function App() {
|
||||
}));
|
||||
}, [playerState.currentArticleId]);
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
if (!inputUrl.trim()) return;
|
||||
|
||||
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => {
|
||||
const normalizedUrl = targetUrl.trim();
|
||||
if (!normalizedUrl) return;
|
||||
|
||||
const existing = queue.find(item => item.url === normalizedUrl);
|
||||
if (existing) {
|
||||
setViewId(existing.id);
|
||||
if (options.autoPlay) playArticle(existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const newArticle: Article = {
|
||||
id,
|
||||
url: inputUrl,
|
||||
url: normalizedUrl,
|
||||
title: 'Fetching info...',
|
||||
text: '',
|
||||
segments: [],
|
||||
currentSegmentIndex: 0,
|
||||
status: PlaybackStatus.LOADING_TEXT
|
||||
};
|
||||
|
||||
|
||||
setQueue(prev => [...prev, newArticle]);
|
||||
setInputUrl('');
|
||||
if (!playerState.isPlaying) setViewId(id);
|
||||
if (!playerState.isPlaying || options.pinView) setViewId(id);
|
||||
|
||||
try {
|
||||
const { title, text } = await extractArticleContent(newArticle.url);
|
||||
const segments = segmentText(text);
|
||||
|
||||
|
||||
if (title) {
|
||||
const titleSegment = segmentText(title)[0];
|
||||
if (titleSegment) segments.unshift(titleSegment);
|
||||
}
|
||||
|
||||
|
||||
updateArticle(id, {
|
||||
title,
|
||||
text,
|
||||
@@ -188,10 +208,10 @@ export default function App() {
|
||||
processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice);
|
||||
}
|
||||
updateArticle(id, { status: PlaybackStatus.READY });
|
||||
|
||||
|
||||
setQueue(prev => {
|
||||
const current = prev.find(a => a.id === id);
|
||||
if (current && !playerState.isPlaying) {
|
||||
if (current && (!playerState.isPlaying || options.autoPlay)) {
|
||||
playArticle(id);
|
||||
}
|
||||
return prev;
|
||||
@@ -201,13 +221,28 @@ export default function App() {
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
updateArticle(id, {
|
||||
status: PlaybackStatus.ERROR,
|
||||
errorMessage: error.message || "Failed to load article"
|
||||
updateArticle(id, {
|
||||
status: PlaybackStatus.ERROR,
|
||||
errorMessage: error.message || "Failed to load article"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
await enqueueArticle(inputUrl, { autoPlay: !playerState.isPlaying, pinView: true });
|
||||
setInputUrl('');
|
||||
};
|
||||
|
||||
const handleStarterPick = async (url: string) => {
|
||||
await enqueueArticle(url, { autoPlay: true, pinView: true });
|
||||
setActiveList('queue');
|
||||
};
|
||||
|
||||
const handleRandomStarter = () => {
|
||||
const pick = starterPicks[Math.floor(Math.random() * starterPicks.length)];
|
||||
if (pick) handleStarterPick(pick.url);
|
||||
};
|
||||
|
||||
// -- Playback Control --
|
||||
|
||||
const playArticle = useCallback(async (id: string) => {
|
||||
@@ -248,6 +283,34 @@ export default function App() {
|
||||
});
|
||||
}, [playerState.currentArticleId]);
|
||||
|
||||
const handleReorder = useCallback((sourceId: string, targetId: string) => {
|
||||
if (sourceId === targetId) return;
|
||||
setQueue(prev => {
|
||||
const sourceIndex = prev.findIndex(a => a.id === sourceId);
|
||||
const targetIndex = prev.findIndex(a => a.id === targetId);
|
||||
if (sourceIndex === -1 || targetIndex === -1) return prev;
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(sourceIndex, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePlayNext = useCallback((articleId: string) => {
|
||||
setQueue(prev => {
|
||||
const sourceIndex = prev.findIndex(a => a.id === articleId);
|
||||
if (sourceIndex === -1) return prev;
|
||||
const updated = [...prev];
|
||||
const [picked] = updated.splice(sourceIndex, 1);
|
||||
const currentId = playerState.currentArticleId;
|
||||
const currentIndex = currentId ? updated.findIndex(a => a.id === currentId) : -1;
|
||||
const insertIndex = currentIndex === -1 ? 0 : currentIndex + 1;
|
||||
updated.splice(insertIndex, 0, picked);
|
||||
return updated;
|
||||
});
|
||||
setActiveList('queue');
|
||||
}, [playerState.currentArticleId]);
|
||||
|
||||
const handleSegmentSelect = useCallback((articleId: string, index: number) => {
|
||||
setPlayerState(prev => ({
|
||||
...prev,
|
||||
@@ -398,6 +461,15 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(prev => {
|
||||
const seen = new Set(prev.map(h => h.id));
|
||||
const completed = queue.filter(item => item.status === PlaybackStatus.COMPLETED && !seen.has(item.id));
|
||||
if (completed.length === 0) return prev;
|
||||
return [...prev, ...completed];
|
||||
});
|
||||
}, [queue]);
|
||||
|
||||
const handleSpeedChange = (newSpeed: number) => {
|
||||
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
|
||||
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
|
||||
@@ -508,6 +580,60 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ambient Tone */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-slate-700 dark:text-slate-300 text-sm font-medium block">Ambient mode</span>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{[
|
||||
{ key: 'clean', label: 'Clean' },
|
||||
{ key: 'sepia', label: 'Sepia' },
|
||||
{ key: 'night', label: 'Night Light' }
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => setSettings(s => ({...s, readingTone: option.key as any}))}
|
||||
className={`px-3 py-2 rounded-lg border transition-all ${settings.readingTone === option.key ? 'border-blue-500 text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30' : 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Width */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">Page width</span>
|
||||
<span className="text-xs text-slate-400">{settings.pageWidth === 'cozy' ? 'Cozy' : settings.pageWidth === 'wide' ? 'Wide' : 'Comfort'}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="1"
|
||||
value={['cozy','standard','wide'].indexOf(settings.pageWidth)}
|
||||
onChange={(e) => {
|
||||
const options = ['cozy','standard','wide'] as const;
|
||||
setSettings(s => ({...s, pageWidth: options[parseInt(e.target.value)]}));
|
||||
}}
|
||||
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zen Mode */}
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Zen mode</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Hide chrome for distraction-free reading.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings(s => ({...s, zenMode: !s.zenMode}))}
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-all ${settings.zenMode ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200' : 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'}`}
|
||||
>
|
||||
{settings.zenMode ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts Hint */}
|
||||
<div className="pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||
@@ -527,64 +653,135 @@ export default function App() {
|
||||
{/* Left Column: Controls & Queue */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
{/* Input */}
|
||||
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 flex gap-2 items-center pl-4 transition-colors duration-300">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Paste article URL here..."
|
||||
className="flex-grow py-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUrl}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Queue</span>
|
||||
</button>
|
||||
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors duration-300 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Paste article URL here..."
|
||||
className="flex-grow py-3 px-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0 rounded-xl bg-slate-50 dark:bg-slate-800"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
||||
/>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
onClick={handleAddUrl}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Queue</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRandomStarter()}
|
||||
className="bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-200 px-4 py-3 rounded-xl font-medium flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" /> Surprise me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Try a curated link below or drop your own URL to get the vibes going.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{starterPicks.map((pick) => (
|
||||
<button
|
||||
key={pick.url}
|
||||
onClick={() => handleStarterPick(pick.url)}
|
||||
className="text-left bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl p-4 hover:border-blue-200 hover:shadow-sm dark:hover:border-blue-800 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
<Sparkles className="w-4 h-4 text-blue-500" /> {pick.label}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{pick.url}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Up Next</h2>
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">{queue.length} articles</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No articles queued.</p>
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-full p-1">
|
||||
{([['queue','Queue'], ['history','History']] as const).map(([key,label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveList(key)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-all ${activeList === key ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-300' : 'text-slate-500 dark:text-slate-400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
queue.map(article => (
|
||||
<div key={article.id} onClick={() => setViewId(article.id)} className="cursor-pointer">
|
||||
<QueueItem
|
||||
article={article}
|
||||
isActive={article.id === playerState.currentArticleId}
|
||||
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
||||
onPlay={() => playArticle(article.id)}
|
||||
onPause={pausePlayback}
|
||||
onRemove={() => {
|
||||
if (playerState.currentArticleId === article.id) {
|
||||
pausePlayback();
|
||||
setPlayerState(prev => ({ ...prev, currentArticleId: null, isPlaying: false }));
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setQueue(prev => {
|
||||
const target = prev.find(a => a.id === article.id);
|
||||
cleanupArticleAudio(target);
|
||||
return prev.filter(a => a.id !== article.id);
|
||||
});
|
||||
setViewId(prev => prev === article.id ? null : prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">
|
||||
{activeList === 'queue' ? `${queue.length} queued` : `${history.length} played`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeList === 'queue' ? (
|
||||
<div className="space-y-3">
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No articles queued.</p>
|
||||
<p className="text-xs mt-1">Tap a vibe above to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
queue.map(article => (
|
||||
<div
|
||||
key={article.id}
|
||||
onClick={() => setViewId(article.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<QueueItem
|
||||
article={article}
|
||||
isActive={article.id === playerState.currentArticleId}
|
||||
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
||||
onPlay={() => playArticle(article.id)}
|
||||
onPause={pausePlayback}
|
||||
onPlayNext={() => handlePlayNext(article.id)}
|
||||
draggable
|
||||
onDragStart={() => setDraggingId(article.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); if (draggingId) handleReorder(draggingId, article.id); setDraggingId(null); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onRemove={() => {
|
||||
if (playerState.currentArticleId === article.id) {
|
||||
pausePlayback();
|
||||
setPlayerState(prev => ({ ...prev, currentArticleId: null, isPlaying: false }));
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setQueue(prev => {
|
||||
const target = prev.find(a => a.id === article.id);
|
||||
cleanupArticleAudio(target);
|
||||
return prev.filter(a => a.id !== article.id);
|
||||
});
|
||||
setViewId(prev => prev === article.id ? null : prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-10 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No history yet.</p>
|
||||
<p className="text-xs mt-1">Finished listens will show up here automatically.</p>
|
||||
</div>
|
||||
) : (
|
||||
history.map(item => (
|
||||
<div key={item.id} className="p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">
|
||||
<HistoryIcon className="w-4 h-4 text-slate-400" />
|
||||
<span className="truncate">{item.title || item.url}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{item.url}</p>
|
||||
<div className="text-[11px] text-green-600 dark:text-green-400 mt-1">Completed</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -628,7 +825,7 @@ export default function App() {
|
||||
</h4>
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${isBuffering ? 'bg-blue-400/50 animate-pulse' : 'bg-blue-500'}`}
|
||||
style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}}
|
||||
/>
|
||||
@@ -636,6 +833,19 @@ export default function App() {
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
|
||||
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 justify-center sm:justify-start text-xs text-blue-500 dark:text-blue-300">
|
||||
<Waves className={`w-4 h-4 ${playerState.isPlaying ? 'animate-pulse' : ''}`} />
|
||||
<div className="flex items-end gap-1 h-4">
|
||||
{[0.8, 1.2, 0.9, 1.4].map((height, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`w-1 rounded-full bg-gradient-to-t from-blue-500 to-indigo-400 ${playerState.isPlaying ? 'animate-[bounce_1.4s_infinite]' : ''}`}
|
||||
style={{ height: `${height * 12}px`, opacity: isBuffering ? 0.4 : 1 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 dark:text-slate-400">Vibe visualizer</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>
|
||||
|
||||
Reference in New Issue
Block a user