+ {/* Header */}
+
+
Generation settings
+ {settingsLocked && (
+
+
+ Locked
+
+ )}
+
- {isLegacyAudiobookMissingSettings && (
-
-
Saved generation settings not found
-
- This audiobook was likely created before v1 metadata was introduced, so OpenReader can’t know
- which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
- v1 metadata (so settings are saved for resumes across devices).
-
-
- )}
-
- {settingsLocked && savedSettings ? (
-
-
- Voice: {savedSettings.voice}
-
-
- Native speed: {formatSpeed(savedSettings.nativeSpeed)}x
-
-
- Post speed: {formatSpeed(savedSettings.postSpeed)}x
-
-
- Format: {savedSettings.format.toUpperCase()}
-
-
- Reset the audiobook to change generation settings.
-
-
- ) : (
-
-
-
-
-
Output format
- {chapters.length === 0 ? (
-
setFormat(newFormat)}
- disabled={chapters.length > 0 || settingsLocked}
- >
-
-
- {format.toUpperCase()}
-
-
-
-
-
-
-
- `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
- }`
- }
- >
- {({ selected }) => (
-
- M4B
-
- )}
-
-
- `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
- }`
- }
- >
- {({ selected }) => (
-
- MP3
-
- )}
-
-
-
-
-
- ) : (
-
{format.toUpperCase()}
- )}
-
-
-
-
-
Native model speed
-
• {formatSpeed(nativeSpeed)}x
-
-
setNativeSpeed(parseFloat(e.target.value))}
- className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
- />
-
-
-
-
-
Post-generation speed
-
• {formatSpeed(postSpeed)}x
-
-
setPostSpeed(parseFloat(e.target.value))}
- className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
- />
+
+ {isLegacyAudiobookMissingSettings && (
+
+
Saved generation settings not found
+
+ This audiobook was likely created before v1 metadata was introduced, so OpenReader can't know
+ which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
+ v1 metadata (so settings are saved for resumes across devices).
)}
-
+ {settingsLocked && savedSettings ? (
+
+
+
+
Voice
+
{savedSettings.voice}
+
+
+
Format
+
{savedSettings.format.toUpperCase()}
+
+
+
+
+
Native speed
+
{formatSpeed(savedSettings.nativeSpeed)}x
+
+
+
Post speed
+
{formatSpeed(savedSettings.postSpeed)}x
+
+
+
+ Reset the audiobook to change generation settings.
+
+
+ ) : (
+
+ {/* Voice & Format row */}
+
+
+ Voice
+
+
+
+
+
Format
+ {chapters.length === 0 ? (
+
setFormat(newFormat)}
+ disabled={chapters.length > 0 || settingsLocked}
+ >
+
+
+ {format.toUpperCase()}
+
+
+
+
+
+
+
+ `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
+ }
+ >
+ {({ selected }) => (
+
+ M4B
+
+ )}
+
+
+ `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
+ }
+ >
+ {({ selected }) => (
+
+ MP3
+
+ )}
+
+
+
+
+
+ ) : (
+
{format.toUpperCase()}
+ )}
+
+
+
+ {/* Speed controls */}
+
+
+
+ Native model speed
+ {formatSpeed(nativeSpeed)}x
+
+
setNativeSpeed(parseFloat(e.target.value))}
+ className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
+ />
+
+ 0.5x
+ 3x
+
+
+
+
+
+
+
+ Post-generation speed
+ {formatSpeed(postSpeed)}x
+
+
setPostSpeed(parseFloat(e.target.value))}
+ className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
+ />
+
+ 0.5x
+ 3x
+
+
+
+
+ )}
+
+ {/* Action buttons */}
+
{chapters.length === 0 && (
setShowResetConfirm(true)}
disabled={isGenerating}
- className="w-full justify-center rounded-lg bg-red-500 px-3 py-2 text-sm
- font-medium text-background hover:bg-red-500/90 focus:outline-none
- focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
- transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
+ className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
+ font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none
+ focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
+ transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
title="Delete all generated chapters/pages for this document"
>
Reset
@@ -716,7 +738,7 @@ export function AudiobookExportModal({
{chapters.length > 0 && (
<>
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx
index cf69efa..2088be9 100644
--- a/src/components/ConfirmDialog.tsx
+++ b/src/components/ConfirmDialog.tsx
@@ -69,7 +69,7 @@ export function ConfirmDialog({
{title}
@@ -85,7 +85,7 @@ export function ConfirmDialog({
onCancel(e)}
>
{cancelText}
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index 5bceae8..a1d1bff 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -6,7 +6,7 @@ import {
ListboxOption,
ListboxOptions,
} from '@headlessui/react';
-import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
+import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
@@ -16,13 +16,36 @@ export function VoicesControlBase({
onChangeVoice,
ttsProvider,
ttsModel,
+ dropdownDirection = 'up',
+ variant = 'compact',
}: {
availableVoices: string[];
voice: string;
onChangeVoice: (voice: string) => void;
ttsProvider: string;
ttsModel: string;
+ dropdownDirection?: 'up' | 'down';
+ variant?: 'compact' | 'field';
}) {
+ const dropdownPosition = dropdownDirection === 'down'
+ ? 'top-full left-0 mt-1'
+ : 'bottom-full right-0 mb-1';
+
+ const buttonClass = variant === 'field'
+ ? 'relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full'
+ : 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent';
+
+ const iconClass = variant === 'field'
+ ? 'h-3.5 w-3.5 shrink-0'
+ : 'h-3 w-3 sm:h-3.5 sm:w-3.5';
+
+ const chevronClass = variant === 'field'
+ ? 'h-4 w-4 text-muted'
+ : 'h-2.5 w-2.5 sm:h-3 sm:w-3';
+
+ const dropdownWidth = variant === 'field'
+ ? 'w-full'
+ : isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
const isKokoro = isKokoroModel(ttsModel);
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
@@ -91,33 +114,70 @@ export function VoicesControlBase({
}
}}
>
-
-
- {selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
-
+
+ {variant === 'field' ? (
+ <>
+
+
+ {selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
+
+ >
+ )}
-
+
{availableVoices.map((voiceId) => (
- `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
+ `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
}
>
- {voiceId}
+ {({ selected }) => (
+ <>
+ {selected ? (
+
+ ) : (
+
+ )}
+ {voiceId}
+ >
+ )}
))}
) : (
-
-
- {currentVoice}
-
+
+ {variant === 'field' ? (
+ <>
+
+
+ {currentVoice}
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {currentVoice}
+
+ >
+ )}
-
+
{availableVoices.map((voiceId) => (