mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 03:33:06 +08:00
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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user