feat: add an option to show persistent TTS bar at the bottom, closes #1818 (#1944)

This commit is contained in:
Huang Xin
2025-09-01 20:23:28 +08:00
committed by GitHub
parent f4b00d6dec
commit 99a4cb50ac
13 changed files with 201 additions and 44 deletions
@@ -183,7 +183,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
<div
className={clsx(
'footer-bar shadow-xs bottom-0 z-50 flex w-full flex-col',
'footer-bar shadow-xs bottom-0 z-30 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
@@ -211,7 +211,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: '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',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
@@ -265,7 +265,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: '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',
}}
>
<Slider
@@ -304,10 +304,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
</div>
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom + 16}px` : '0px',
paddingBottom: isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px',
}}
>
<Button
@@ -337,7 +337,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom / 2}px` : '0px',
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
}}
>
<Button
@@ -392,7 +392,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
</div>
</div>
<TTSControl bookKey={bookKey} />
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
@@ -62,7 +62,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
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<PageInfoProps> = ({
: {
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 ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
<div
className={clsx(
'flex items-center justify-center',
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
</div>
</div>
);
};
@@ -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();
@@ -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(() => {
@@ -189,15 +189,15 @@ const Notebook: React.FC = ({}) => {
return isNotebookVisible ? (
<>
{!isNotebookPinned && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
<div className='overlay fixed inset-0 z-[45] bg-black/20' onClick={handleClickOverlay} />
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'notebook-container bg-base-200 right-0 flex min-w-60 select-none flex-col',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -16,11 +18,14 @@ const SidebarContent: React.FC<{
sideBarBookKey: string;
}> = ({ 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);
@@ -181,13 +181,19 @@ const SideBar: React.FC<{
return isSideBarVisible ? (
<>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-[45] bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
<div
className={clsx(
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
'sidebar-container bg-base-200 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
'transition-[padding-top] duration-300',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
isSideBarPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
@@ -266,12 +272,6 @@ const SideBar: React.FC<{
onTouchStart={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
</>
) : null;
};
@@ -26,7 +26,7 @@ const TabNavigation: React.FC<{
>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'bg-base-300 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
@@ -36,7 +36,7 @@ const TabNavigation: React.FC<{
{tabs.map((tab) => (
<div
key={tab}
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
className='lg:tooltip lg:tooltip-top z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
data-tip={
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
@@ -0,0 +1,69 @@
import clsx from 'clsx';
import React from 'react';
import { MdPlayArrow, MdOutlinePause, MdFastRewind, MdFastForward } from 'react-icons/md';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
type TTSBarProps = {
bookKey: string;
isPlaying: boolean;
onTogglePlay: () => 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 (
<div
className={clsx(
'bg-base-100 absolute bottom-0 z-40',
'inset-x-0 mx-auto flex w-full justify-center sm:w-fit',
'transition-opacity duration-300',
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
)}
style={{ paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0 }}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey('')}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='text-base-content flex h-[52px] items-center space-x-2 px-2'>
<button
onClick={onBackward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
<MdFastRewind size={iconSize32} />
</button>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
{isPlaying ? <MdOutlinePause size={iconSize48} /> : <MdPlayArrow size={iconSize48} />}
</button>
<button
onClick={onForward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
<MdFastForward size={iconSize32} />
</button>
</div>
</div>
);
};
export default TTSBar;
@@ -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<TTSControlProps> = ({ bookKey }) => {
const TTSControl: React.FC<TTSControlProps> = ({ 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<string>('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<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -44,7 +49,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | 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<TTSControlProps> = ({ 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<TTSControlProps> = ({ 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<TTSControlProps> = ({ bookKey }) => {
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
onSelectTimeout={handleSelectTimeout}
onToogleTTSBar={handleToggleTTSBar}
/>
</Popup>
)}
{showIndicator && showTTSBar && ttsClientsInited && (
<TTSBar
bookKey={bookKey}
isPlaying={isPlaying}
onBackward={handleBackward}
onTogglePlay={handleTogglePlay}
onForward={handleForward}
gridInsets={gridInsets}
/>
)}
</>
);
};
@@ -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 (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl px-4 pt-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
@@ -231,23 +234,32 @@ const TTSPanel = ({
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='rounded-full p-1'>
<button
onClick={onBackward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
<MdFastRewind size={iconSize32} />
</button>
<button onClick={onTogglePlay} className='rounded-full p-1'>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
{isPlaying ? (
<MdPauseCircle size={iconSize48} className='fill-primary' />
) : (
<MdPlayCircle size={iconSize48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='rounded-full p-1'>
<button
onClick={onForward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
>
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
className='flex flex-col items-center justify-center rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<MdAlarm size={iconSize32} />
@@ -293,7 +305,7 @@ const TTSPanel = ({
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='rounded-full p-1'
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<RiVoiceAiFill size={iconSize32} />
@@ -350,6 +362,15 @@ const TTSPanel = ({
</ul>
</div>
</div>
<div className='flex h-6 items-center justify-center opacity-60 transition-transform duration-200 hover:scale-105 hover:opacity-100'>
<button onClick={onToogleTTSBar} className='p-0'>
{viewSettings?.showTTSBar ? (
<TbChevronCompactUp size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
) : (
<TbChevronCompactDown size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
)}
</button>
</div>
</div>
);
};
@@ -187,6 +187,7 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.3,
ttsVoice: '',
ttsLocation: '',
showTTSBar: false,
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
+1
View File
@@ -160,6 +160,7 @@ export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
ttsLocation: string;
showTTSBar: boolean;
}
export interface TranslatorConfig {