) => {
+ // React fires range onChange continuously during a drag; track the value
+ // and commit only on pointer/key release.
+ const value = parseFloat(e.target.value);
+ dragValueRef.current = value;
+ setDragValue(value);
+ };
+
+ const handlePointerCommit = () => {
+ if (dragValueRef.current !== null) commitSeek(dragValueRef.current);
+ };
+
+ const handleKeyUp = () => {
+ // Holding an arrow key must not fire a network seek per press.
+ if (keyboardCommitRef.current) clearTimeout(keyboardCommitRef.current);
+ keyboardCommitRef.current = setTimeout(() => {
+ if (dragValueRef.current !== null) commitSeek(dragValueRef.current);
+ }, 500);
+ };
+
+ const total = displayTotal ?? info?.duration ?? 0;
+ const position = Math.min(dragValue ?? info?.position ?? 0, total);
+ const ready = info !== null && total > 0;
+ const forceHours = total >= 3600;
+ const step = Math.max(5, Math.round(total / 100)) || 5;
+ const elapsedLabel = ready ? formatPlaybackTime(position, forceHours) : '--:--';
+ const remainingLabel = ready
+ ? `-${formatPlaybackTime(Math.max(total - position, 0), forceHours)}`
+ : '--:--';
+
+ return (
+
+ {elapsedLabel}
+ {/* Plain native range, matching the footer's Jump to Location slider:
+ thin track, small thumb, visually unmistakable for the chunky rate
+ slider above. */}
+
+ {remainingLabel}
+
+ );
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -116,6 +279,9 @@ const TTSPanel = ({
onGetVoiceId,
onSelectTimeout,
onToogleTTSBar,
+ onSeek,
+ onGetPlaybackInfo,
+ hasTimeline,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
@@ -233,6 +399,14 @@ const TTSPanel = ({
{_('Fast')}
+ {hasTimeline && (
+
+ )}