parent
30184817da
commit
51316217af
@ -66,7 +66,6 @@ export interface PlaybackState {
|
||||
updateTime: number;
|
||||
/**
|
||||
* the playback rate
|
||||
* Not implemented yet. Always 1.0.
|
||||
*/
|
||||
// rate: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user