feat(core): audio playback rate (#11702)

fix AF-2470
This commit is contained in:
pengx17 2025-04-16 07:49:12 +00:00
parent 30184817da
commit 51316217af
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
9 changed files with 135 additions and 8 deletions

View File

@ -66,7 +66,6 @@ export interface PlaybackState {
updateTime: number;
/**
* the playback rate
* Not implemented yet. Always 1.0.
*/
// rate: number;
playbackRate: number;
}

View File

@ -149,6 +149,13 @@ export const timeDisplay = style({
},
});
export const playbackRateDisplay = style({
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
cursor: 'pointer',
});
export const miniRoot = style({
position: 'relative',
display: 'flex',

View File

@ -12,6 +12,7 @@ const AudioWrapper = () => {
const [seekTime, setSeekTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1.0);
const audioRef = useRef<HTMLAudioElement>(null);
const audioUrlRef = useRef<string | null>(null);
@ -151,6 +152,13 @@ const AudioWrapper = () => {
[playbackState]
);
const handlePlaybackRateChange = useCallback((rate: number) => {
if (audioRef.current) {
audioRef.current.playbackRate = rate;
setPlaybackRate(rate);
}
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioFile) return;
@ -298,6 +306,8 @@ const AudioWrapper = () => {
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
playbackRate={playbackRate}
onPlaybackRateChange={handlePlaybackRateChange}
/>
<AudioPlayer
name={audioFile.name}
@ -311,6 +321,8 @@ const AudioWrapper = () => {
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
playbackRate={playbackRate}
onPlaybackRateChange={handlePlaybackRateChange}
/>
</>
)}

View File

@ -8,8 +8,9 @@ import bytes from 'bytes';
import { clamp } from 'lodash-es';
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
import { IconButton } from '../button';
import { Button, IconButton } from '../button';
import { AnimatedPlayIcon } from '../lottie';
import { Menu, MenuItem } from '../menu';
import * as styles from './audio-player.css';
import { AudioWaveform } from './audio-waveform';
@ -40,8 +41,15 @@ export interface AudioPlayerProps {
onPause: MouseEventHandler;
onStop: MouseEventHandler;
onSeek: (newTime: number) => void;
// Playback rate
playbackRate: number;
onPlaybackRateChange: (rate: number) => void;
}
// Playback rate options
const playbackRates = [0.5, 0.75, 1, 1.5, 1.75, 2, 3];
export const AudioPlayer = ({
name,
size,
@ -55,6 +63,8 @@ export const AudioPlayer = ({
onPause,
onSeek,
onClick,
playbackRate,
onPlaybackRateChange,
}: AudioPlayerProps) => {
// Handle progress bar click
const handleProgressClick = useCallback(
@ -80,6 +90,13 @@ export const AudioPlayer = ({
[loading, playbackState, onPause, onPlay]
);
const handlePlaybackRateChange = useCallback(
(rate: number) => {
onPlaybackRateChange(rate);
},
[onPlaybackRateChange]
);
// Calculate progress percentage
const progressPercentage = duration > 0 ? seekTime / duration : 0;
return (
@ -97,6 +114,27 @@ export const AudioPlayer = ({
</div>
</div>
<div className={styles.upperRight}>
<Menu
rootOptions={{ modal: false }}
children={
<Button variant="plain" className={styles.playbackRateDisplay}>
{playbackRate}x
</Button>
}
items={
<>
{playbackRates.map(rate => (
<MenuItem
key={rate}
selected={rate === playbackRate}
onClick={() => handlePlaybackRateChange(rate)}
>
{rate}x
</MenuItem>
))}
</>
}
/>
{notesEntry}
<AnimatedPlayIcon
onClick={handlePlayToggle}

View File

@ -58,6 +58,13 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
[audioMedia]
);
const handlePlaybackRateChange = useCallback(
(rate: number) => {
audioMedia?.setPlaybackRate(rate);
},
[audioMedia]
);
const t = useI18n();
const enableAi = useEnableAI();
@ -193,6 +200,8 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
playbackRate={playbackState?.playbackRate || 1.0}
onPlaybackRateChange={handlePlaybackRateChange}
notesEntry={
<CurrentServerScopeProvider>{notesEntry}</CurrentServerScopeProvider>
}

View File

@ -10,6 +10,7 @@ export const useSeekTime = (
state: AudioMediaPlaybackState;
seekOffset: number;
updateTime: number;
playbackRate: number;
}
| undefined
| null,
@ -24,7 +25,8 @@ export const useSeekTime = (
if (playbackState) {
const timeElapsed =
playbackState.state === 'playing'
? (Date.now() - playbackState.updateTime) / 1000
? ((Date.now() - playbackState.updateTime) / 1000) *
(playbackState.playbackRate ?? 1.0)
: 0;
// if timeElapsed + playbackState.seekOffset is close to duration,
// set seekTime to duration

View File

@ -99,6 +99,13 @@ export const SidebarAudioPlayer = () => {
[audioMediaManagerService]
);
const handlePlaybackRateChange = useCallback(
(rate: number) => {
audioMediaManagerService.setPlaybackRate(rate);
},
[audioMediaManagerService]
);
const handlePlayerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@ -130,6 +137,8 @@ export const SidebarAudioPlayer = () => {
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
playbackRate={playbackState.playbackRate || 1.0}
onPlaybackRateChange={handlePlaybackRateChange}
waveform={playbackStats.waveform}
/>
</div>

View File

@ -37,6 +37,7 @@ export interface AudioMediaSyncState {
state: AudioMediaPlaybackState;
seekOffset: number;
updateTime: number; // the time when the playback state is updated
playbackRate: number;
}
/**
@ -75,6 +76,13 @@ export class AudioMedia extends Entity<AudioSource> {
this.revalidateBuffer();
// React to playbackState$ changes to update playbackRate and media session
const playbackStateSub = this.playbackState$.subscribe(state => {
this.audioElement.playbackRate = state.playbackRate;
this.updateMediaSessionPositionState(this.audioElement.currentTime);
});
this.disposables.push(() => playbackStateSub.unsubscribe());
this.disposables.push(() => {
// Clean up audio resources before calling super.dispose
try {
@ -108,7 +116,6 @@ export class AudioMedia extends Entity<AudioSource> {
loadError$ = new LiveData<Error | null>(null);
waveform$ = new LiveData<number[] | null>(null);
duration$ = new LiveData<number | null>(null);
/**
* LiveData that exposes the current playback state and data for global state synchronization
*/
@ -116,6 +123,7 @@ export class AudioMedia extends Entity<AudioSource> {
state: 'idle',
seekOffset: 0,
updateTime: 0,
playbackRate: 1.0,
});
stats$ = LiveData.computed(get => {
@ -129,12 +137,15 @@ export class AudioMedia extends Entity<AudioSource> {
private updatePlaybackState(
state: AudioMediaPlaybackState,
seekOffset: number,
updateTime = Date.now()
updateTime = Date.now(),
playbackRate?: number
) {
const prev = this.playbackState$.getValue();
this.playbackState$.setValue({
state,
seekOffset,
updateTime,
playbackRate: playbackRate ?? prev.playbackRate ?? 1.0,
});
}
@ -239,11 +250,12 @@ export class AudioMedia extends Entity<AudioSource> {
}
const duration = this.audioElement.duration || 0;
const playbackRate = this.playbackState$.getValue().playbackRate ?? 1.0;
if (duration > 0) {
navigator.mediaSession.setPositionState({
duration,
position: seekTime,
playbackRate: 1.0,
playbackRate,
});
}
}
@ -360,7 +372,12 @@ export class AudioMedia extends Entity<AudioSource> {
if (state.updateTime <= currentState.updateTime) {
return;
}
this.updatePlaybackState(state.state, state.seekOffset, state.updateTime);
this.updatePlaybackState(
state.state,
state.seekOffset,
state.updateTime,
state.playbackRate
);
if (state.state !== currentState.state) {
if (state.state === 'playing') {
this.play(true);
@ -371,6 +388,7 @@ export class AudioMedia extends Entity<AudioSource> {
}
}
this.seekTo(state.seekOffset, true);
this.audioElement.playbackRate = state.playbackRate ?? 1.0;
}
/**
@ -435,4 +453,19 @@ export class AudioMedia extends Entity<AudioSource> {
return waveform;
}
/**
* Set the playback rate (speed) of the audio and update the shared state
*/
setPlaybackRate(rate: number) {
// Clamp the rate to a reasonable range (e.g., 0.5x to 4x)
const clamped = clamp(rate, 0.5, 4.0);
const prev = this.playbackState$.getValue();
this.updatePlaybackState(
prev.state,
this.getCurrentSeekPosition(),
Date.now(),
clamped
);
}
}

View File

@ -231,6 +231,24 @@ export class AudioMediaManagerService extends Service {
});
}
/**
* Sets the playback rate (speed) for the current audio
* @param rate The playback rate (0.5 to 4.0)
*/
setPlaybackRate(rate: number) {
const state = this.getGlobalPlaybackState();
if (!state) {
return;
}
const clamped = clamp(rate, 0.5, 4.0);
this.globalMediaState.updatePlaybackState({
...state,
playbackRate: clamped,
updateTime: Date.now(),
});
}
focusAudioMedia(key: AudioMediaKey, tabId: string | null) {
const mediaProps = parseAudioMediaKey(key);
if (tabId === this.currentTabId) {