1+ <script setup lang="ts">
2+ import { useReadAloudEngine } from ' ../composables/useReadAloudEngine'
3+
4+ const {
5+ isSupported,
6+ ttsMode,
7+ isPlaying,
8+ isPaused,
9+ isLoadingModel,
10+ loadingProgress,
11+ currentRate,
12+ rates,
13+ puterVoices,
14+ selectedPuterVoice,
15+ browserVoices,
16+ selectedBrowserVoiceIndex,
17+ isBrowserSupported,
18+ togglePlayPause,
19+ stopSpeech,
20+ setRate,
21+ onEngineChange,
22+ onVoiceChange,
23+ } = useReadAloudEngine ()
24+ </script >
25+
26+ <template >
27+ <div v-if =" isSupported" class =" read-aloud-container" >
28+ <div class =" read-aloud-bar" >
29+ <div class =" read-aloud-left" >
30+ <button
31+ class =" action-btn main-btn"
32+ :class =" { active: isPlaying && !isPaused, loading: isLoadingModel }"
33+ :disabled =" isLoadingModel"
34+ @click =" togglePlayPause"
35+ :title =" isPlaying && !isPaused ? 'Pause Reading' : (isPaused ? 'Resume Reading' : 'Listen to article')"
36+ >
37+ <svg v-if =" isLoadingModel" class =" spinner" viewBox =" 0 0 24 24" width =" 18" height =" 18" >
38+ <circle class =" path" cx =" 12" cy =" 12" r =" 10" fill =" none" stroke-width =" 3" ></circle >
39+ </svg >
40+ <svg v-else-if =" !isPlaying || isPaused" xmlns =" http://www.w3.org/2000/svg" width =" 18" height =" 18" viewBox =" 0 0 24 24" fill =" currentColor" >
41+ <path d =" M8 5v14l11-7z" />
42+ </svg >
43+ <svg v-else xmlns =" http://www.w3.org/2000/svg" width =" 18" height =" 18" viewBox =" 0 0 24 24" fill =" currentColor" >
44+ <path d =" M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
45+ </svg >
46+ <span >{{ isLoadingModel ? 'Loading...' : (isPlaying && !isPaused ? 'Pause' : (isPaused ? 'Resume' : 'Listen')) }}</span >
47+ </button >
48+
49+ <button
50+ v-if =" isPlaying || isPaused"
51+ class =" action-btn stop-btn"
52+ @click =" stopSpeech"
53+ title =" Stop Reading"
54+ >
55+ <svg xmlns =" http://www.w3.org/2000/svg" width =" 16" height =" 16" viewBox =" 0 0 24 24" fill =" currentColor" >
56+ <path d =" M6 6h12v12H6z" />
57+ </svg >
58+ </button >
59+
60+ <span v-if =" isLoadingModel" class =" loading-text" >{{ loadingProgress }}</span >
61+ </div >
62+
63+ <div class =" read-aloud-right" >
64+ <!-- TTS Engine Selector -->
65+ <select
66+ v-model =" ttsMode"
67+ @change =" onEngineChange"
68+ class =" mode-select"
69+ title =" Select TTS Engine"
70+ >
71+ <option value =" puter" >Puter AI (Natural OpenAI)</option >
72+ <option v-if =" isBrowserSupported" value =" browser" >Browser Native (Offline)</option >
73+ </select >
74+
75+ <!-- Voice Selector for Puter AI -->
76+ <select
77+ v-if =" ttsMode === 'puter'"
78+ v-model =" selectedPuterVoice"
79+ @change =" onVoiceChange"
80+ class =" voice-select"
81+ title =" Select Puter AI Voice"
82+ >
83+ <option
84+ v-for =" voice in puterVoices"
85+ :key =" voice.id"
86+ :value =" voice.id"
87+ >
88+ {{ voice.name }}
89+ </option >
90+ </select >
91+
92+ <!-- Voice Selector for Browser Native -->
93+ <select
94+ v-else-if =" ttsMode === 'browser' && isBrowserSupported"
95+ v-model =" selectedBrowserVoiceIndex"
96+ @change =" onVoiceChange"
97+ class =" voice-select"
98+ title =" Select Browser Voice"
99+ >
100+ <option
101+ v-for =" (voice, index) in browserVoices"
102+ :key =" voice.name"
103+ :value =" index"
104+ >
105+ {{ voice.name }} ({{ voice.lang }})
106+ </option >
107+ </select >
108+
109+ <!-- Speed control -->
110+ <div class =" speed-selector" >
111+ <button
112+ v-for =" rate in rates"
113+ :key =" rate"
114+ class =" rate-pill"
115+ :class =" { active: currentRate === rate }"
116+ @click =" setRate(rate)"
117+ >
118+ {{ rate }}x
119+ </button >
120+ </div >
121+ </div >
122+ </div >
123+ </div >
124+ </template >
125+
126+ <style scoped>
127+ .read-aloud-container {
128+ margin : 1rem 0 ;
129+ padding : 0.75rem ;
130+ background : var (--vp-c-bg-soft );
131+ border : 1px solid var (--vp-c-divider );
132+ border-radius : 8px ;
133+ }
134+
135+ .read-aloud-bar {
136+ display : flex ;
137+ align-items : center ;
138+ justify-content : space-between ;
139+ gap : 0.75rem ;
140+ flex-wrap : wrap ;
141+ }
142+
143+ .read-aloud-left ,
144+ .read-aloud-right {
145+ display : flex ;
146+ align-items : center ;
147+ gap : 0.5rem ;
148+ }
149+
150+ .loading-text {
151+ font-size : 0.8rem ;
152+ color : var (--vp-c-brand-1 );
153+ font-weight : 500 ;
154+ }
155+
156+ .action-btn {
157+ display : inline-flex ;
158+ align-items : center ;
159+ gap : 0.4rem ;
160+ padding : 0.4rem 0.8rem ;
161+ border-radius : 6px ;
162+ font-size : 0.875rem ;
163+ font-weight : 600 ;
164+ cursor : pointer ;
165+ border : 1px solid var (--vp-c-divider );
166+ background : var (--vp-c-bg-alt );
167+ color : var (--vp-c-text-1 );
168+ transition : all 0.2s ease ;
169+ }
170+
171+ .action-btn :hover:not (:disabled ) {
172+ border-color : var (--vp-c-brand-1 );
173+ color : var (--vp-c-brand-1 );
174+ }
175+
176+ .action-btn.main-btn {
177+ background : var (--vp-c-brand-1 );
178+ color : #ffffff ;
179+ border-color : var (--vp-c-brand-1 );
180+ }
181+
182+ .action-btn.main-btn :hover:not (:disabled ) {
183+ background : var (--vp-c-brand-2 );
184+ border-color : var (--vp-c-brand-2 );
185+ color : #ffffff ;
186+ }
187+
188+ .action-btn.main-btn.active {
189+ background : #eab308 ;
190+ border-color : #eab308 ;
191+ color : #000000 ;
192+ }
193+
194+ .action-btn.stop-btn {
195+ padding : 0.4rem 0.5rem ;
196+ color : var (--vp-c-text-2 );
197+ }
198+
199+ .action-btn.stop-btn :hover {
200+ border-color : #ef4444 ;
201+ color : #ef4444 ;
202+ }
203+
204+ .mode-select ,
205+ .voice-select {
206+ padding : 0.35rem 0.6rem ;
207+ border-radius : 6px ;
208+ font-size : 0.8rem ;
209+ background : var (--vp-c-bg-alt );
210+ color : var (--vp-c-text-1 );
211+ border : 1px solid var (--vp-c-divider );
212+ cursor : pointer ;
213+ max-width : 220px ;
214+ }
215+
216+ .mode-select :focus ,
217+ .voice-select :focus {
218+ outline : none ;
219+ border-color : var (--vp-c-brand-1 );
220+ }
221+
222+ .speed-selector {
223+ display : flex ;
224+ background : var (--vp-c-bg-alt );
225+ border : 1px solid var (--vp-c-divider );
226+ border-radius : 6px ;
227+ padding : 2px ;
228+ }
229+
230+ .rate-pill {
231+ border : none ;
232+ background : transparent ;
233+ padding : 0.25rem 0.45rem ;
234+ font-size : 0.75rem ;
235+ font-weight : 500 ;
236+ color : var (--vp-c-text-2 );
237+ border-radius : 4px ;
238+ cursor : pointer ;
239+ transition : all 0.15s ease ;
240+ }
241+
242+ .rate-pill :hover {
243+ color : var (--vp-c-text-1 );
244+ }
245+
246+ .rate-pill.active {
247+ background : var (--vp-c-brand-1 );
248+ color : #ffffff ;
249+ font-weight : 600 ;
250+ }
251+
252+ .spinner {
253+ animation : rotate 1.5s linear infinite ;
254+ }
255+
256+ .spinner .path {
257+ stroke : currentColor ;
258+ stroke-linecap : round ;
259+ animation : dash 1.5s ease-in-out infinite ;
260+ }
261+
262+ @keyframes rotate {
263+ 100% {
264+ transform : rotate (360deg );
265+ }
266+ }
267+
268+ @keyframes dash {
269+ 0% {
270+ stroke-dasharray : 1 , 150 ;
271+ stroke-dashoffset : 0 ;
272+ }
273+ 50% {
274+ stroke-dasharray : 90 , 150 ;
275+ stroke-dashoffset : -35 ;
276+ }
277+ 100% {
278+ stroke-dasharray : 90 , 150 ;
279+ stroke-dashoffset : -124 ;
280+ }
281+ }
282+
283+ @media (max-width : 640px ) {
284+ .read-aloud-bar {
285+ flex-direction : column ;
286+ align-items : stretch ;
287+ }
288+ .read-aloud-right {
289+ justify-content : space-between ;
290+ }
291+ .mode-select ,
292+ .voice-select {
293+ max-width : 100% ;
294+ flex : 1 ;
295+ }
296+ }
297+ </style >
298+
299+ <style >
300+ /* Global text highlighting styling for currently read DOM element */
301+ .read-aloud-highlight {
302+ background-color : #fef08a !important ;
303+ color : #0f172a !important ;
304+ border-radius : 6px ;
305+ padding : 2px 6px ;
306+ transition : all 0.3s ease-in-out ;
307+ box-shadow : 0 0 0 2px #fde047 , 0 4px 6px -1px rgba (234 , 179 , 8 , 0.2 );
308+ }
309+
310+ .dark .read-aloud-highlight {
311+ background-color : rgba (234 , 179 , 8 , 0.3 ) !important ;
312+ color : #fef9c3 !important ;
313+ border-radius : 6px ;
314+ box-shadow : 0 0 0 2px rgba (234 , 179 , 8 , 0.5 ), 0 4px 6px -1px rgba (0 , 0 , 0 , 0.3 );
315+ }
316+ </style >
0 commit comments