feat(core): add reload button to audio block (#12451)

Related to: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-loading-和错误样式)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added a reload button with an icon for audio blocks, allowing users to retry loading audio files if an error occurs.
  - Error messages are now displayed with actionable options when audio loading fails.

- **Enhancements**
  - Audio file sizes are now shown in a human-readable format within the audio player.
  - Improved display of audio file information, including error messages and formatted descriptions.

- **Style**
  - Updated styling for audio player and audio block components, including new styles for error states and reload button.
  - Renamed and refined audio player description styling for better layout and spacing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fundon
2025-05-23 06:04:07 +00:00
parent 3d9b13c53c
commit e2e00688a9
7 changed files with 72 additions and 24 deletions
@@ -60,8 +60,9 @@ export const spacer = style({
flex: 1, flex: 1,
}); });
export const sizeInfo = style({ export const description = style({
display: 'flex', display: 'flex',
gap: '8px',
alignItems: 'center', alignItems: 'center',
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'), color: cssVarV2('text/secondary'),
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { useCallback, useEffect, useRef, useState } from 'react'; import bytes from 'bytes';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AudioPlayer, MiniAudioPlayer } from './audio-player'; import { AudioPlayer, MiniAudioPlayer } from './audio-player';
@@ -159,6 +160,10 @@ const AudioWrapper = () => {
} }
}, []); }, []);
const description = useMemo(() => {
return audioFile ? <>{bytes(audioFile.size)}</> : null;
}, [audioFile]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || !audioFile) return; if (!audio || !audioFile) return;
@@ -296,7 +301,7 @@ const AudioWrapper = () => {
/> />
<MiniAudioPlayer <MiniAudioPlayer
name={audioFile.name} name={audioFile.name}
size={audioFile.size} description={description}
waveform={waveform} waveform={waveform}
playbackState={playbackState} playbackState={playbackState}
seekTime={seekTime} seekTime={seekTime}
@@ -311,7 +316,7 @@ const AudioWrapper = () => {
/> />
<AudioPlayer <AudioPlayer
name={audioFile.name} name={audioFile.name}
size={audioFile.size} description={description}
waveform={waveform} waveform={waveform}
playbackState={playbackState} playbackState={playbackState}
seekTime={seekTime} seekTime={seekTime}
@@ -4,7 +4,6 @@ import {
RewindFifteenSecondsIcon, RewindFifteenSecondsIcon,
VoiceIcon, VoiceIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import bytes from 'bytes';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { type MouseEventHandler, type ReactNode, useCallback } from 'react'; import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
@@ -24,7 +23,7 @@ const formatTime = (seconds: number): string => {
export interface AudioPlayerProps { export interface AudioPlayerProps {
// Audio metadata // Audio metadata
name: string; name: string;
size: number | ReactNode; // the size entry may be used for drawing error message description?: ReactNode; // Display file size or error message
waveform: number[] | null; waveform: number[] | null;
// Playback state // Playback state
playbackState: 'idle' | 'playing' | 'paused' | 'stopped'; playbackState: 'idle' | 'playing' | 'paused' | 'stopped';
@@ -52,7 +51,7 @@ const playbackRates = [0.5, 0.75, 1, 1.5, 1.75, 2, 3];
export const AudioPlayer = ({ export const AudioPlayer = ({
name, name,
size, description,
playbackState, playbackState,
seekTime, seekTime,
duration, duration,
@@ -108,9 +107,7 @@ export const AudioPlayer = ({
<div className={styles.nameLabel}>{name}</div> <div className={styles.nameLabel}>{name}</div>
</div> </div>
<div className={styles.upperRow}> <div className={styles.upperRow}>
<div className={styles.sizeInfo}> <div className={styles.description}>{description}</div>
{typeof size === 'number' ? bytes(size) : size}
</div>
</div> </div>
</div> </div>
<div className={styles.upperRight}> <div className={styles.upperRight}>
@@ -28,5 +28,24 @@ export const notesButtonIcon = style({
}); });
export const error = style({ export const error = style({
display: 'flex',
color: cssVarV2('aI/errorText'), color: cssVarV2('aI/errorText'),
}); });
export const reloadButton = style({
display: 'flex',
alignItems: 'center',
padding: '0 4px',
gap: '4px',
border: 'none',
background: 'none',
cursor: 'pointer',
outline: 'none',
color: cssVarV2('button/primary'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const reloadButtonIcon = style({
fontSize: 16,
});
@@ -16,7 +16,9 @@ import { AudioAttachmentService } from '@affine/core/modules/media/services/audi
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import type { AttachmentBlockModel } from '@blocksuite/affine/model'; import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import { ResetIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import bytes from 'bytes';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { AttachmentViewerProps } from '../types'; import type { AttachmentViewerProps } from '../types';
@@ -31,6 +33,8 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
const [preflightChecking, setPreflightChecking] = useState(false); const [preflightChecking, setPreflightChecking] = useState(false);
const transcribing = const transcribing =
useLiveData(block.transcriptionJob.transcribing$) || preflightChecking; useLiveData(block.transcriptionJob.transcribing$) || preflightChecking;
const loading = useLiveData(audioMedia.loading$);
const loadingError = useLiveData(audioMedia.loadError$);
const error = useLiveData(block.transcriptionJob.error$); const error = useLiveData(block.transcriptionJob.error$);
const transcribed = useLiveData(block.hasTranscription$); const transcribed = useLiveData(block.hasTranscription$);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
@@ -65,6 +69,10 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
[audioMedia] [audioMedia]
); );
const reload = useCallback(() => {
audioMedia?.revalidateBuffer();
}, [audioMedia]);
const t = useI18n(); const t = useI18n();
const enableAi = useEnableAI(); const enableAi = useEnableAI();
@@ -179,17 +187,30 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
return inner; return inner;
}, [enableAi, transcribing, handleNotesClick, t]); }, [enableAi, transcribing, handleNotesClick, t]);
const sizeEntry = useMemo(() => { const descriptionEntry = useMemo(() => {
if (error) { if (loadingError) {
return (
<>
<div className={styles.error}>{loadingError.message}</div>
<button className={styles.reloadButton} onClick={reload}>
<ResetIcon className={styles.reloadButtonIcon} />
Reload
</button>
</>
);
}
if (!loading && error) {
return <div className={styles.error}>{error.message}</div>; return <div className={styles.error}>{error.message}</div>;
} }
return block.props.props.size;
}, [error, block.props.props.size]); return <>{bytes(block.props.props.size)}</>;
}, [loading, loadingError, error, reload, block.props.props.size]);
return ( return (
<AudioPlayer <AudioPlayer
name={block.props.props.name} name={block.props.props.name}
size={sizeEntry} description={descriptionEntry}
loading={stats.duration === 0} loading={stats.duration === 0}
playbackState={playbackState?.state || 'idle'} playbackState={playbackState?.state || 'idle'}
waveform={stats.waveform} waveform={stats.waveform}
@@ -234,8 +255,8 @@ const useAttachmentMediaBlock = (model: AttachmentBlockModel) => {
return audioAttachmentBlock; return audioAttachmentBlock;
}; };
export const AudioBlockEmbedded = (props: AttachmentViewerProps) => { export const AudioBlockEmbedded = ({ model }: AttachmentViewerProps) => {
const audioAttachmentBlock = useAttachmentMediaBlock(props.model); const audioAttachmentBlock = useAttachmentMediaBlock(model);
const transcriptionBlock = useLiveData( const transcriptionBlock = useLiveData(
audioAttachmentBlock?.transcriptionBlock$ audioAttachmentBlock?.transcriptionBlock$
); );
@@ -130,7 +130,6 @@ export const SidebarAudioPlayer = () => {
<MiniAudioPlayer <MiniAudioPlayer
playbackState={playbackState.state} playbackState={playbackState.state}
name={playbackStats.name} name={playbackStats.name}
size={playbackStats.size}
duration={playbackStats.duration} duration={playbackStats.duration}
seekTime={seekTime} seekTime={seekTime}
onPlay={handlePlay} onPlay={handlePlay}
@@ -132,6 +132,9 @@ export class AudioMedia extends Entity<AudioSource> {
return { waveform, duration }; return { waveform, duration };
}); });
// `MediaSession` is available
private readonly available = 'mediaSession' in navigator;
private readonly audioElement: HTMLAudioElement; private readonly audioElement: HTMLAudioElement;
private updatePlaybackState( private updatePlaybackState(
@@ -194,7 +197,10 @@ export class AudioMedia extends Entity<AudioSource> {
`Calculate audio stats time: ${performance.now() - startTime}ms` `Calculate audio stats time: ${performance.now() - startTime}ms`
); );
}), }),
onStart(() => this.loading$.setValue(true)), onStart(() => {
this.loadError$.setValue(null);
this.loading$.setValue(true);
}),
onComplete(() => { onComplete(() => {
this.loading$.setValue(false); this.loading$.setValue(false);
}), }),
@@ -212,7 +218,7 @@ export class AudioMedia extends Entity<AudioSource> {
} }
private setupMediaSession() { private setupMediaSession() {
if (!('mediaSession' in navigator)) { if (!this.available) {
return; return;
} }
@@ -237,14 +243,14 @@ export class AudioMedia extends Entity<AudioSource> {
} }
private updateMediaSessionMetadata() { private updateMediaSessionMetadata() {
if (!('mediaSession' in navigator) || !this.props.metadata) { if (!this.available || !this.props.metadata) {
return; return;
} }
navigator.mediaSession.metadata = this.props.metadata; navigator.mediaSession.metadata = this.props.metadata;
} }
private updateMediaSessionPositionState(seekTime: number) { private updateMediaSessionPositionState(seekTime: number) {
if (!('mediaSession' in navigator)) { if (!this.available) {
return; return;
} }
@@ -260,7 +266,7 @@ export class AudioMedia extends Entity<AudioSource> {
} }
private updateMediaSessionPlaybackState(state: AudioMediaPlaybackState) { private updateMediaSessionPlaybackState(state: AudioMediaPlaybackState) {
if (!('mediaSession' in navigator)) { if (!this.available) {
return; return;
} }
@@ -270,7 +276,7 @@ export class AudioMedia extends Entity<AudioSource> {
} }
private cleanupMediaSession() { private cleanupMediaSession() {
if (!('mediaSession' in navigator)) { if (!this.available) {
return; return;
} }
navigator.mediaSession.metadata = null; navigator.mediaSession.metadata = null;