diff --git a/packages/common/infra/src/media/types.ts b/packages/common/infra/src/media/types.ts index ca4b8ffc94..8a2644c721 100644 --- a/packages/common/infra/src/media/types.ts +++ b/packages/common/infra/src/media/types.ts @@ -66,7 +66,6 @@ export interface PlaybackState { updateTime: number; /** * the playback rate - * Not implemented yet. Always 1.0. */ - // rate: number; + playbackRate: number; } diff --git a/packages/frontend/component/src/ui/audio-player/audio-player.css.ts b/packages/frontend/component/src/ui/audio-player/audio-player.css.ts index d3b7b9fdc4..32fb524936 100644 --- a/packages/frontend/component/src/ui/audio-player/audio-player.css.ts +++ b/packages/frontend/component/src/ui/audio-player/audio-player.css.ts @@ -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', diff --git a/packages/frontend/component/src/ui/audio-player/audio-player.stories.tsx b/packages/frontend/component/src/ui/audio-player/audio-player.stories.tsx index 9bd13f9094..b3e79378ac 100644 --- a/packages/frontend/component/src/ui/audio-player/audio-player.stories.tsx +++ b/packages/frontend/component/src/ui/audio-player/audio-player.stories.tsx @@ -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(null); const audioUrlRef = useRef(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} /> { onPause={handlePause} onStop={handleStop} onSeek={handleSeek} + playbackRate={playbackRate} + onPlaybackRateChange={handlePlaybackRateChange} /> )} diff --git a/packages/frontend/component/src/ui/audio-player/audio-player.tsx b/packages/frontend/component/src/ui/audio-player/audio-player.tsx index ea8b53dfab..97080966ac 100644 --- a/packages/frontend/component/src/ui/audio-player/audio-player.tsx +++ b/packages/frontend/component/src/ui/audio-player/audio-player.tsx @@ -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 = ({
+ + {playbackRate}x + + } + items={ + <> + {playbackRates.map(rate => ( + handlePlaybackRateChange(rate)} + > + {rate}x + + ))} + + } + /> {notesEntry} { [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={ {notesEntry} } diff --git a/packages/frontend/core/src/components/hooks/use-seek-time.ts b/packages/frontend/core/src/components/hooks/use-seek-time.ts index 7d01ddf74d..d32017ce10 100644 --- a/packages/frontend/core/src/components/hooks/use-seek-time.ts +++ b/packages/frontend/core/src/components/hooks/use-seek-time.ts @@ -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 diff --git a/packages/frontend/core/src/components/root-app-sidebar/sidebar-audio-player.tsx b/packages/frontend/core/src/components/root-app-sidebar/sidebar-audio-player.tsx index 385f05e900..4dcee443c3 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/sidebar-audio-player.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/sidebar-audio-player.tsx @@ -99,6 +99,13 @@ export const SidebarAudioPlayer = () => { [audioMediaManagerService] ); + const handlePlaybackRateChange = useCallback( + (rate: number) => { + audioMediaManagerService.setPlaybackRate(rate); + }, + [audioMediaManagerService] + ); + const handlePlayerClick = useCallback( (e: React.MouseEvent) => { 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} />
diff --git a/packages/frontend/core/src/modules/media/entities/audio-media.ts b/packages/frontend/core/src/modules/media/entities/audio-media.ts index 0e4b47c092..9ecd619e7a 100644 --- a/packages/frontend/core/src/modules/media/entities/audio-media.ts +++ b/packages/frontend/core/src/modules/media/entities/audio-media.ts @@ -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 { 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 { loadError$ = new LiveData(null); waveform$ = new LiveData(null); duration$ = new LiveData(null); - /** * LiveData that exposes the current playback state and data for global state synchronization */ @@ -116,6 +123,7 @@ export class AudioMedia extends Entity { state: 'idle', seekOffset: 0, updateTime: 0, + playbackRate: 1.0, }); stats$ = LiveData.computed(get => { @@ -129,12 +137,15 @@ export class AudioMedia extends Entity { 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 { } 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 { 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 { } } this.seekTo(state.seekOffset, true); + this.audioElement.playbackRate = state.playbackRate ?? 1.0; } /** @@ -435,4 +453,19 @@ export class AudioMedia extends Entity { 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 + ); + } } diff --git a/packages/frontend/core/src/modules/media/services/audio-media-manager.ts b/packages/frontend/core/src/modules/media/services/audio-media-manager.ts index 935df0a33e..3a1f79854b 100644 --- a/packages/frontend/core/src/modules/media/services/audio-media-manager.ts +++ b/packages/frontend/core/src/modules/media/services/audio-media-manager.ts @@ -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) {