From 99a4cb50ac51b4b39149534bd8dfdab895a16f10 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 1 Sep 2025 20:23:28 +0800 Subject: [PATCH] feat: add an option to show persistent TTS bar at the bottom, closes #1818 (#1944) --- .../src/app/reader/components/FooterBar.tsx | 14 ++-- .../app/reader/components/ProgressInfo.tsx | 23 ++++--- .../src/app/reader/components/Reader.tsx | 21 ++++++ .../app/reader/components/ReaderContent.tsx | 7 +- .../reader/components/notebook/Notebook.tsx | 6 +- .../app/reader/components/sidebar/Content.tsx | 12 +++- .../app/reader/components/sidebar/SideBar.tsx | 16 ++--- .../components/sidebar/TabNavigation.tsx | 4 +- .../src/app/reader/components/tts/TTSBar.tsx | 69 +++++++++++++++++++ .../app/reader/components/tts/TTSControl.tsx | 38 ++++++++-- .../app/reader/components/tts/TTSPanel.tsx | 33 +++++++-- apps/readest-app/src/services/constants.ts | 1 + apps/readest-app/src/types/book.ts | 1 + 13 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/tts/TTSBar.tsx diff --git a/apps/readest-app/src/app/reader/components/FooterBar.tsx b/apps/readest-app/src/app/reader/components/FooterBar.tsx index 280daa9a..0558ddbc 100644 --- a/apps/readest-app/src/app/reader/components/FooterBar.tsx +++ b/apps/readest-app/src/app/reader/components/FooterBar.tsx @@ -183,7 +183,7 @@ const FooterBar: React.FC = ({ />
= ({ : 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in', )} style={{ - bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px', + bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px', }} >
@@ -265,7 +265,7 @@ const FooterBar: React.FC = ({ : 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in', )} style={{ - bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px', + bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px', }} > = ({
- + ); }; diff --git a/apps/readest-app/src/app/reader/components/ProgressInfo.tsx b/apps/readest-app/src/app/reader/components/ProgressInfo.tsx index 9ec181cf..589957f9 100644 --- a/apps/readest-app/src/app/reader/components/ProgressInfo.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressInfo.tsx @@ -62,7 +62,7 @@ const ProgressInfoView: React.FC = ({ className={clsx( 'progressinfo absolute bottom-0 flex items-center justify-between', 'text-neutral-content font-sans text-xs font-extralight', - isVertical ? 'writing-vertical-rl' : 'h-[52px] w-full', + isVertical ? 'writing-vertical-rl' : 'w-full', isScrolled && !isVertical && 'bg-base-100', )} style={ @@ -78,16 +78,23 @@ const ProgressInfoView: React.FC = ({ : { paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`, paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`, - paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.67}px` : 0, + paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0, } } > - {viewSettings.showRemainingTime ? ( - {timeLeft} - ) : viewSettings.showRemainingPages ? ( - {pageLeft} - ) : null} - {viewSettings.showProgressInfo && {progressInfo}} +
+ {viewSettings.showRemainingTime ? ( + {timeLeft} + ) : viewSettings.showRemainingPages ? ( + {pageLeft} + ) : null} + {viewSettings.showProgressInfo && {progressInfo}} +
); }; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index d621fe14..0f632b18 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -28,6 +28,27 @@ import { getLocale } from '@/utils/misc'; import { initDayjs } from '@/utils/time'; import ReaderContent from './ReaderContent'; +/* +Z-Index Layering Guide: +--------------------------------- +99 – Window Border (Linux only) + • Ensures the border stays on top of all UI elements. +50 – Loading Progress / Toast Notifications / Dialogs + • Includes Settings, About, Updater, and KOSync dialogs. +45 – Sidebar / Notebook (Unpinned) + • Floats above the content but below global dialogs. +40 – TTS Bar + • Mini controls for TTS playback on top of the TTS Control. +30 – Footbar (TTS Control) + • Persistent bottom controls and the TTS icon/panel. +20 – Menu / Sidebar / Notebook (Pinned) + • Docked navigation or note views. +10 – Headerbar / Ribbon + • Top toolbar and ribbon elements. + 0 – Base Content + • Main reading area or background content. +*/ + const Reader: React.FC<{ ids?: string }> = ({ ids }) => { const router = useRouter(); const { envConfig, appService } = useEnv(); diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 14af63af..0eb7b743 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -67,10 +67,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i if (index === 0) setSideBarBookKey(key); } }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { const handleShowBookDetails = (event: CustomEvent) => { - const book = event.detail as Book; - setShowDetailsBook(book); + setShowDetailsBook(event.detail as Book); return true; }; eventDispatcher.onSync('show-book-details', handleShowBookDetails); @@ -78,7 +80,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i return () => { eventDispatcher.offSync('show-book-details', handleShowBookDetails); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index 03b4b725..7f2ecde7 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -189,15 +189,15 @@ const Notebook: React.FC = ({}) => { return isNotebookVisible ? ( <> {!isNotebookPinned && ( -
+
)}
= ({ bookDoc, sideBarBookKey }) => { const { safeAreaInsets } = useThemeStore(); + const { setHoveredBookKey } = useReaderStore(); + const { setSideBarVisible } = useSidebarStore(); const { getConfig, setConfig } = useBookDataStore(); const config = getConfig(sideBarBookKey); const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc'); const [fade, setFade] = useState(false); const [targetTab, setTargetTab] = useState(activeTab); + const isMobile = window.innerWidth < 640 || window.innerHeight < 640; useEffect(() => { if (!sideBarBookKey) return; @@ -32,8 +37,13 @@ const SidebarContent: React.FC<{ const handleTabChange = (tab: string) => { setFade(true); const timeout = setTimeout(() => { - setFade(false); + if (activeTab === tab && isMobile) { + setHoveredBookKey(sideBarBookKey); + setSideBarVisible(false); + return; + } setTargetTab(tab); + setFade(false); setConfig(sideBarBookKey!, config); clearTimeout(timeout); }, 300); diff --git a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx index 46874a4f..0c9fccb8 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx @@ -181,13 +181,19 @@ const SideBar: React.FC<{ return isSideBarVisible ? ( <> + {!isSideBarPinned && ( +
+ )}
- {!isSideBarPinned && ( -
- )} ) : null; }; diff --git a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx index c6e6b526..44ec41f9 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx @@ -26,7 +26,7 @@ const TabNavigation: React.FC<{ >
(
void; + onBackward: () => void; + onForward: () => void; + gridInsets: Insets; +}; + +const TTSBar = ({ + bookKey, + isPlaying, + onTogglePlay, + onBackward, + onForward, + gridInsets, +}: TTSBarProps) => { + const { appService } = useEnv(); + const { hoveredBookKey, setHoveredBookKey } = useReaderStore(); + const iconSize32 = useResponsiveSize(30); + const iconSize48 = useResponsiveSize(36); + + const isVisible = hoveredBookKey !== bookKey; + + return ( +
!appService?.isMobile && setHoveredBookKey('')} + onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')} + > +
+ + + +
+
+ ); +}; + +export default TTSBar; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 54542893..ac496255 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -13,30 +13,35 @@ import { parseSSMLLang } from '@/utils/ssml'; import { throttle } from '@/utils/throttle'; import { invokeUseBackgroundAudio } from '@/utils/bridge'; import { CFI } from '@/libs/document'; +import { Insets } from '@/types/misc'; import Popup from '@/components/Popup'; import TTSPanel from './TTSPanel'; import TTSIcon from './TTSIcon'; +import TTSBar from './TTSBar'; const POPUP_WIDTH = 282; -const POPUP_HEIGHT = 160; +const POPUP_HEIGHT = 180; const POPUP_PADDING = 10; interface TTSControlProps { bookKey: string; + gridInsets: Insets; } -const TTSControl: React.FC = ({ bookKey }) => { +const TTSControl: React.FC = ({ bookKey, gridInsets }) => { const _ = useTranslation(); const { appService } = useEnv(); const { safeAreaInsets } = useThemeStore(); const { getBookData } = useBookDataStore(); - const { getView, getProgress, getViewSettings } = useReaderStore(); + const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore(); const { setViewSettings, setTTSEnabled } = useReaderStore(); + const viewSettings = getViewSettings(bookKey); const [ttsLang, setTtsLang] = useState('en'); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [showIndicator, setShowIndicator] = useState(false); const [showPanel, setShowPanel] = useState(false); + const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar); const [panelPosition, setPanelPosition] = useState(); const [trianglePosition, setTrianglePosition] = useState(); @@ -44,7 +49,6 @@ const TTSControl: React.FC = ({ bookKey }) => { const [timeoutTimestamp, setTimeoutTimestamp] = useState(0); const [timeoutFunc, setTimeoutFunc] = useState | null>(null); - const viewSettings = getViewSettings(bookKey); const popupPadding = useResponsiveSize(POPUP_PADDING); const maxWidth = window.innerWidth - 2 * popupPadding; const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH)); @@ -363,6 +367,16 @@ const TTSControl: React.FC = ({ bookKey }) => { } }; + const handleToggleTTSBar = () => { + const viewSettings = getViewSettings(bookKey)!; + viewSettings.showTTSBar = !viewSettings.showTTSBar; + setShowTTSBar(viewSettings.showTTSBar); + if (viewSettings.showTTSBar) { + setShowPanel(false); + } + setViewSettings(bookKey, viewSettings); + }; + const updatePanelPosition = () => { if (iconRef.current) { const rect = iconRef.current.getBoundingClientRect(); @@ -430,12 +444,13 @@ const TTSControl: React.FC = ({ bookKey }) => { ref={iconRef} className={clsx( 'absolute h-12 w-12', - viewSettings?.rtl ? 'left-6' : 'right-6', + 'transition-transform duration-300', + viewSettings?.rtl ? 'left-8' : 'right-6', !appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14', )} style={{ bottom: appService?.hasSafeAreaInset - ? `${(safeAreaInsets?.bottom || 0) + 70}px` + ? `${(safeAreaInsets?.bottom || 0) * 0.33 + (hoveredBookKey ? 70 : 52)}px` : undefined, }} > @@ -464,9 +479,20 @@ const TTSControl: React.FC = ({ bookKey }) => { onSetVoice={handleSetVoice} onGetVoiceId={handleGetVoiceId} onSelectTimeout={handleSelectTimeout} + onToogleTTSBar={handleToggleTTSBar} /> )} + {showIndicator && showTTSBar && ttsClientsInited && ( + + )} ); }; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx index 87f47154..acfb6ee6 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import React, { useState, ChangeEvent, useEffect } from 'react'; import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md'; +import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb'; import { RiVoiceAiFill } from 'react-icons/ri'; import { MdCheck } from 'react-icons/md'; import { TTSVoicesGroup } from '@/services/tts'; @@ -25,6 +26,7 @@ type TTSPanelProps = { onSetVoice: (voice: string, lang: string) => void; onGetVoiceId: () => string; onSelectTimeout: (bookKey: string, value: number) => void; + onToogleTTSBar: () => void; }; const getTTSTimeoutOptions = (_: TranslationFunc) => { @@ -113,6 +115,7 @@ const TTSPanel = ({ onSetVoice, onGetVoiceId, onSelectTimeout, + onToogleTTSBar, }: TTSPanelProps) => { const _ = useTranslation(); const { envConfig } = useEnv(); @@ -200,7 +203,7 @@ const TTSPanel = ({ const timeoutOptions = getTTSTimeoutOptions(_); return ( -
+
- - -
+
+ +
); }; diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index cbaab2d6..91297078 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -187,6 +187,7 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = { ttsRate: 1.3, ttsVoice: '', ttsLocation: '', + showTTSBar: false, }; export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 2c0d72de..733b646b 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -160,6 +160,7 @@ export interface TTSConfig { ttsRate: number; ttsVoice: string; ttsLocation: string; + showTTSBar: boolean; } export interface TranslatorConfig {