mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
feat(editor): audio block (#10947)
AudioMedia entity for loading & controlling a single audio media AudioMediaManagerService: Global audio state synchronization across tabs AudioAttachmentService + AudioAttachmentBlock for manipulating AttachmentBlock in affine - e.g., filling transcription (using mock endpoint for now) Added AudioBlock + AudioPlayer for rendering audio block in affine (new transcription block whose renderer is provided in affine) fix AF-2292 fix AF-2337
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
BlockComponent,
|
||||
BlockViewExtension,
|
||||
} from '@blocksuite/affine/block-std';
|
||||
import type { TranscriptionBlockModel } from '@blocksuite/affine/model';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import { css, type PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
export class LitTranscriptionBlock extends BlockComponent<TranscriptionBlockModel> {
|
||||
static override styles = [
|
||||
css`
|
||||
transcription-block {
|
||||
outline: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
override render() {
|
||||
return this.std.host.renderChildren(this.model);
|
||||
}
|
||||
|
||||
@property({ type: String, attribute: 'data-block-id' })
|
||||
override accessor blockId!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// questionable:
|
||||
this.widgets = {};
|
||||
|
||||
// to allow text selection across paragraphs in the callout block
|
||||
this.contentEditable = 'true';
|
||||
}
|
||||
|
||||
override firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.disposables.addFromEvent(this, 'click', this.onClick);
|
||||
}
|
||||
|
||||
protected onClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
export const AITranscriptionBlockSpec: ExtensionType[] = [
|
||||
BlockViewExtension('affine:transcription', () => {
|
||||
return literal`transcription-block`;
|
||||
}),
|
||||
];
|
||||
@@ -2,6 +2,7 @@ import { SpecProvider } from '@blocksuite/affine/shared/utils';
|
||||
|
||||
import { AIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-block';
|
||||
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
|
||||
import { LitTranscriptionBlock } from './blocks/ai-chat-block/ai-transcription-block';
|
||||
import {
|
||||
AIChatMessage,
|
||||
AIChatMessages,
|
||||
@@ -151,5 +152,7 @@ export function registerAIEffects() {
|
||||
EdgelessCopilotToolbarEntry
|
||||
);
|
||||
|
||||
customElements.define('transcription-block', LitTranscriptionBlock);
|
||||
|
||||
SpecProvider._.extendSpec('store', [AIChatBlockSchemaExtension]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SpecBuilder } from '@blocksuite/affine/shared/utils';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { AIChatBlockSpec } from '../blocks';
|
||||
import { AITranscriptionBlockSpec } from '../blocks/ai-chat-block/ai-transcription-block';
|
||||
import { AICodeBlockSpec } from './ai-code';
|
||||
import { createAIEdgelessRootBlockSpec } from './ai-edgeless-root';
|
||||
import { AIImageBlockSpec } from './ai-image';
|
||||
@@ -39,4 +40,5 @@ export function enableAIExtension(
|
||||
}
|
||||
|
||||
specBuilder.extend(AIChatBlockSpec);
|
||||
specBuilder.extend(AITranscriptionBlockSpec);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AudioBlockEmbedded } from './audio/audio-block';
|
||||
import { AttachmentPreviewErrorBoundary } from './error';
|
||||
import { PDFViewerEmbedded } from './pdf/pdf-viewer-embedded';
|
||||
import type { AttachmentViewerProps } from './types';
|
||||
import { getAttachmentType } from './utils';
|
||||
|
||||
// In Embed view
|
||||
export const AttachmentEmbedPreview = ({ model }: AttachmentViewerProps) => {
|
||||
const attachmentType = getAttachmentType(model);
|
||||
const element = useMemo(() => {
|
||||
switch (attachmentType) {
|
||||
case 'pdf':
|
||||
return <PDFViewerEmbedded model={model} />;
|
||||
case 'audio':
|
||||
return <AudioBlockEmbedded model={model} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [attachmentType, model]);
|
||||
return (
|
||||
<AttachmentPreviewErrorBoundary>{element}</AttachmentPreviewErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
});
|
||||
|
||||
export const notesButton = style({
|
||||
padding: '4px 8px',
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const notesButtonIcon = style({
|
||||
fontSize: 24,
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { AudioPlayer } from '@affine/core/components/audio-player';
|
||||
import { useSeekTime } from '@affine/core/components/audio-player/use-seek-time';
|
||||
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
|
||||
import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block';
|
||||
import { useAttachmentMediaBlock } from '@affine/core/modules/media/views/use-attachment-media';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { TranscriptWithAiIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { AttachmentViewerProps } from '../types';
|
||||
import * as styles from './audio-block.css';
|
||||
import { TranscriptionBlock } from './transcription-block';
|
||||
|
||||
const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
|
||||
const audioMedia = block.audioMedia;
|
||||
const playbackState = useLiveData(audioMedia.playbackState$);
|
||||
const stats = useLiveData(audioMedia.stats$);
|
||||
const loading = useLiveData(audioMedia.loading$);
|
||||
const expanded = useLiveData(block.expanded$);
|
||||
const transcribing = useLiveData(block.transcribing$);
|
||||
const transcribed = useLiveData(block.transcribed$);
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const seekTime = useSeekTime(playbackState, stats.duration);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
audioMedia?.play();
|
||||
}, [audioMedia]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
audioMedia?.pause();
|
||||
}, [audioMedia]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
audioMedia?.stop();
|
||||
}, [audioMedia]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
audioMedia?.seekTo(time);
|
||||
},
|
||||
[audioMedia]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const enableAi = useEnableAI();
|
||||
|
||||
const notesEntry = useMemo(() => {
|
||||
if (!enableAi) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="plain"
|
||||
prefix={<TranscriptWithAiIcon />}
|
||||
loading={transcribing}
|
||||
disabled={transcribing}
|
||||
size="large"
|
||||
prefixClassName={styles.notesButtonIcon}
|
||||
className={styles.notesButton}
|
||||
onClick={() => {
|
||||
if (transcribed) {
|
||||
block.expanded$.setValue(!expanded);
|
||||
} else {
|
||||
block.transcribe();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{transcribing
|
||||
? t['com.affine.attachmentViewer.audio.transcribing']()
|
||||
: t['com.affine.attachmentViewer.audio.notes']()}
|
||||
</Button>
|
||||
);
|
||||
}, [enableAi, transcribing, t, transcribed, block, expanded]);
|
||||
|
||||
return (
|
||||
<AudioPlayer
|
||||
name={block.props.props.name}
|
||||
size={block.props.props.size}
|
||||
loading={loading}
|
||||
playbackState={playbackState?.state || 'idle'}
|
||||
waveform={stats.waveform}
|
||||
seekTime={seekTime}
|
||||
duration={stats.duration}
|
||||
onClick={handleClick}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
notesEntry={notesEntry}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AudioBlockEmbedded = (props: AttachmentViewerProps) => {
|
||||
const audioAttachmentBlock = useAttachmentMediaBlock(props.model);
|
||||
const transcriptionBlock = useLiveData(
|
||||
audioAttachmentBlock?.transcriptionBlock$
|
||||
);
|
||||
const expanded = useLiveData(audioAttachmentBlock?.expanded$);
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{audioAttachmentBlock && (
|
||||
<AttachmentAudioPlayer block={audioAttachmentBlock} />
|
||||
)}
|
||||
{transcriptionBlock && expanded && (
|
||||
<TranscriptionBlock block={transcriptionBlock} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
borderRadius: 6,
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createReactComponentFromLit } from '@affine/component';
|
||||
import { LitTranscriptionBlock } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/ai-transcription-block';
|
||||
import type { TranscriptionBlockModel } from '@blocksuite/affine/model';
|
||||
import { LiveData, useLiveData } from '@toeverything/infra';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as styles from './transcription-block.css';
|
||||
|
||||
const AdaptedTranscriptionBlock = createReactComponentFromLit({
|
||||
react: React,
|
||||
elementClass: LitTranscriptionBlock,
|
||||
});
|
||||
|
||||
export const TranscriptionBlock = ({
|
||||
block,
|
||||
}: {
|
||||
block: TranscriptionBlockModel;
|
||||
}) => {
|
||||
const childMap$ = useMemo(
|
||||
() => LiveData.fromSignal(block.childMap),
|
||||
[block.childMap]
|
||||
);
|
||||
const childMap = useLiveData(childMap$);
|
||||
const onDragStart = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
if (childMap.size === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.root}
|
||||
// draggable + onDragStart to blacklist blocksuite's default drag behavior for attachment block
|
||||
draggable="true"
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<AdaptedTranscriptionBlock blockId={block.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export { viewer } from './viewer.css';
|
||||
|
||||
export const error = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const errorTitle = style({
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2('text/primary'),
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const errorMessage = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
});
|
||||
|
||||
export const errorBtns = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
marginTop: '28px',
|
||||
});
|
||||
+3
-3
@@ -7,7 +7,7 @@ import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import * as styles from './error.css';
|
||||
import { download } from './utils';
|
||||
|
||||
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
|
||||
@@ -67,7 +67,7 @@ interface ErrorBaseProps {
|
||||
buttons?: ReactElement[];
|
||||
}
|
||||
|
||||
export const ErrorBase = ({
|
||||
const ErrorBase = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon = <FileIcon />,
|
||||
@@ -88,7 +88,7 @@ interface ErrorProps {
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export const Error = ({ model, ext }: ErrorProps) => {
|
||||
export const AttachmentFallback = ({ model, ext }: ErrorProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = FILE_ICONS[model.props.type] ?? FileIcon;
|
||||
const title = t['com.affine.attachment.preview.error.title']();
|
||||
+7
-7
@@ -1,11 +1,11 @@
|
||||
import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
|
||||
|
||||
import { AttachmentPreviewErrorBoundary, Error } from './error';
|
||||
import { PDFViewer } from './pdf-viewer';
|
||||
import * as styles from './styles.css';
|
||||
import { Titlebar } from './titlebar';
|
||||
import type { AttachmentViewerProps, PDFViewerProps } from './types';
|
||||
import { AttachmentFallback, AttachmentPreviewErrorBoundary } from './error';
|
||||
import { PDFViewer } from './pdf/pdf-viewer';
|
||||
import type { AttachmentViewerBaseProps, AttachmentViewerProps } from './types';
|
||||
import { buildAttachmentProps } from './utils';
|
||||
import { Titlebar } from './viewer';
|
||||
import * as styles from './viewer.css';
|
||||
|
||||
// In Peek view
|
||||
export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
|
||||
@@ -35,12 +35,12 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentViewerInner = (props: PDFViewerProps) => {
|
||||
const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => {
|
||||
return props.model.props.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
<AttachmentFallback {...props} />
|
||||
);
|
||||
};
|
||||
+12
-22
@@ -28,9 +28,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { AttachmentViewerProps } from '../types';
|
||||
import * as styles from './styles.css';
|
||||
import * as embeddedStyles from './styles.embedded.css';
|
||||
import type { PDFViewerProps } from './types';
|
||||
|
||||
function defaultMeta() {
|
||||
return {
|
||||
@@ -40,7 +39,7 @@ function defaultMeta() {
|
||||
};
|
||||
}
|
||||
|
||||
export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
||||
export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
||||
const scale = window.devicePixelRatio;
|
||||
const peekView = useService(PeekViewService).peekView;
|
||||
const pdfService = useService(PDFService);
|
||||
@@ -194,8 +193,8 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
|
||||
<main className={embeddedStyles.pdfViewer}>
|
||||
<div ref={viewerRef} className={styles.pdfContainer}>
|
||||
<main className={styles.pdfViewer}>
|
||||
<div
|
||||
className={styles.pdfPage}
|
||||
style={{
|
||||
@@ -216,11 +215,11 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={embeddedStyles.pdfControls}>
|
||||
<div className={styles.pdfControls}>
|
||||
<IconButton
|
||||
size={16}
|
||||
icon={<ArrowUpSmallIcon />}
|
||||
className={embeddedStyles.pdfControlButton}
|
||||
className={styles.pdfControlButton}
|
||||
onDoubleClick={stopPropagation}
|
||||
aria-label="Prev"
|
||||
{...navigator.prev}
|
||||
@@ -228,7 +227,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
||||
<IconButton
|
||||
size={16}
|
||||
icon={<ArrowDownSmallIcon />}
|
||||
className={embeddedStyles.pdfControlButton}
|
||||
className={styles.pdfControlButton}
|
||||
onDoubleClick={stopPropagation}
|
||||
aria-label="Next"
|
||||
{...navigator.next}
|
||||
@@ -236,27 +235,18 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
||||
<IconButton
|
||||
size={16}
|
||||
icon={<CenterPeekIcon />}
|
||||
className={embeddedStyles.pdfControlButton}
|
||||
className={styles.pdfControlButton}
|
||||
onDoubleClick={stopPropagation}
|
||||
{...navigator.peek}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<footer className={embeddedStyles.pdfFooter}>
|
||||
<div
|
||||
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
|
||||
>
|
||||
<footer className={styles.pdfFooter}>
|
||||
<div className={clsx([styles.pdfFooterItem, { truncate: true }])}>
|
||||
<AttachmentIcon />
|
||||
<span className={clsx([embeddedStyles.pdfTitle, 'pdf-name'])}>
|
||||
{name}
|
||||
</span>
|
||||
<span className={clsx([styles.pdfTitle, 'pdf-name'])}>{name}</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx([
|
||||
embeddedStyles.pdfFooterItem,
|
||||
embeddedStyles.pdfPageCount,
|
||||
])}
|
||||
>
|
||||
<div className={clsx([styles.pdfFooterItem, styles.pdfPageCount])}>
|
||||
<span className="page-cursor">
|
||||
{meta.pageCount > 0 ? cursor + 1 : '-'}
|
||||
</span>
|
||||
+61
-6
@@ -1,21 +1,21 @@
|
||||
import { IconButton, observeResize } from '@affine/component';
|
||||
import type {
|
||||
PDF,
|
||||
PDFRendererState,
|
||||
PDFStatus,
|
||||
} from '@affine/core/modules/pdf';
|
||||
import type { PDF, PDFRendererState } from '@affine/core/modules/pdf';
|
||||
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||
import {
|
||||
Item,
|
||||
List,
|
||||
ListPadding,
|
||||
ListWithSmallGap,
|
||||
LoadingSvg,
|
||||
PDFPageRenderer,
|
||||
type PDFVirtuosoContext,
|
||||
type PDFVirtuosoProps,
|
||||
Scroller,
|
||||
ScrollSeekPlaceholder,
|
||||
} from '@affine/core/modules/pdf/views';
|
||||
import track from '@affine/track';
|
||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
@@ -24,11 +24,21 @@ import {
|
||||
type VirtuosoHandle,
|
||||
} from 'react-virtuoso';
|
||||
|
||||
import type { AttachmentViewerProps } from '../types';
|
||||
import * as styles from './styles.css';
|
||||
import { calculatePageNum, fitToPage } from './utils';
|
||||
import { fitToPage } from './utils';
|
||||
|
||||
const THUMBNAIL_WIDTH = 94;
|
||||
|
||||
function calculatePageNum(el: HTMLElement, pageCount: number) {
|
||||
const { scrollTop, scrollHeight } = el;
|
||||
const pageHeight = scrollHeight / pageCount;
|
||||
const n = scrollTop / pageHeight;
|
||||
const t = n / pageCount;
|
||||
const index = Math.floor(n + t);
|
||||
const cursor = Math.min(index, pageCount - 1);
|
||||
return cursor;
|
||||
}
|
||||
export interface PDFViewerInnerProps {
|
||||
pdf: PDF;
|
||||
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
||||
@@ -234,3 +244,48 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function PDFViewerStatus({
|
||||
pdf,
|
||||
...props
|
||||
}: AttachmentViewerProps & { pdf: PDF }) {
|
||||
const state = useLiveData(pdf.state$);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== PDFStatus.Error) return;
|
||||
|
||||
track.$.attachment.$.openPDFRendererFail();
|
||||
}, [state]);
|
||||
|
||||
if (state?.status !== PDFStatus.Opened) {
|
||||
return <PDFLoading />;
|
||||
}
|
||||
|
||||
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model, ...props }: AttachmentViewerProps) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { pdf, release } = pdfService.get(model);
|
||||
setPdf(pdf);
|
||||
|
||||
return () => {
|
||||
release();
|
||||
};
|
||||
}, [model, pdfService, setPdf]);
|
||||
|
||||
if (!pdf) {
|
||||
return <PDFLoading />;
|
||||
}
|
||||
|
||||
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
||||
}
|
||||
|
||||
const PDFLoading = () => (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<LoadingSvg />
|
||||
</div>
|
||||
);
|
||||
+93
-104
@@ -1,115 +1,13 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const viewerContainer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const titlebar = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
height: '52px',
|
||||
padding: '10px 8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
borderTopWidth: '0.5px',
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: cssVarV2('layer/insideBorder/border'),
|
||||
textWrap: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const titlebarChild = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${titlebar} > &`]: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
'&.zoom:not(.show)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const titlebarName = style({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'pre',
|
||||
wordWrap: 'break-word',
|
||||
});
|
||||
export { viewer } from '../viewer.css';
|
||||
|
||||
export const virtuoso = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const error = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const errorTitle = style({
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2('text/primary'),
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const errorMessage = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
});
|
||||
|
||||
export const errorBtns = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
marginTop: '28px',
|
||||
});
|
||||
|
||||
export const viewer = style({
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
resize: 'none',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
position: 'absolute',
|
||||
content: '',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
'&:not(.gridding):before': {
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
'&.gridding:before': {
|
||||
opacity: 0.25,
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfIndicator = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -184,3 +82,94 @@ export const pdfThumbnail = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfContainer = style({
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
userSelect: 'none',
|
||||
contentVisibility: 'visible',
|
||||
display: 'flex',
|
||||
minHeight: 'fit-content',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const pdfViewer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '12px',
|
||||
overflow: 'hidden',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const pdfPlaceholder = style({
|
||||
position: 'absolute',
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
overflow: 'hidden',
|
||||
height: 'auto',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const pdfControls = style({
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
right: '14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const pdfControlButton = style({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVar('--affine-border-color'),
|
||||
background: cssVar('--affine-white'),
|
||||
});
|
||||
|
||||
export const pdfFooter = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
textWrap: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfFooterItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&.truncate': {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfTitle = style({
|
||||
marginLeft: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfPageCount = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
|
||||
|
||||
export function fitToPage(
|
||||
viewportInfo: PageSize,
|
||||
actualSize: PageSize,
|
||||
maxSize: PageSize,
|
||||
isThumbnail?: boolean
|
||||
) {
|
||||
const { width: vw, height: vh } = viewportInfo;
|
||||
const { width: w, height: h } = actualSize;
|
||||
const { width: mw, height: mh } = maxSize;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
if (h / w > vh / vw) {
|
||||
height = vh * (h / mh);
|
||||
width = (w / h) * height;
|
||||
} else {
|
||||
const t = isThumbnail ? Math.min(w / h, 1) : w / mw;
|
||||
width = vw * t;
|
||||
height = (h / w) * width;
|
||||
}
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
aspectRatio: width / height,
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
|
||||
export type AttachmentType = 'pdf' | 'image' | 'audio' | 'video' | 'unknown';
|
||||
|
||||
export type AttachmentViewerProps = {
|
||||
model: AttachmentBlockModel;
|
||||
};
|
||||
|
||||
export type PDFViewerProps = {
|
||||
export type AttachmentViewerBaseProps = {
|
||||
model: AttachmentBlockModel;
|
||||
name: string;
|
||||
ext: string;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
import { downloadBlob } from '../../utils/resource';
|
||||
import type { AttachmentViewerBaseProps } from './types';
|
||||
|
||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||
const sourceId = model.props.sourceId;
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
let blob = await doc.blobSync.get(sourceId);
|
||||
|
||||
if (blob) {
|
||||
blob = new Blob([blob], { type: model.props.type });
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function download(model: AttachmentBlockModel) {
|
||||
const blob = await getAttachmentBlob(model);
|
||||
if (!blob) return;
|
||||
|
||||
await downloadBlob(blob, model.props.name);
|
||||
}
|
||||
|
||||
export function buildAttachmentProps(
|
||||
model: AttachmentBlockModel
|
||||
): AttachmentViewerBaseProps {
|
||||
const pieces = model.props.name.split('.');
|
||||
const ext = pieces.pop() || '';
|
||||
const name = pieces.join('.');
|
||||
const size = filesize(model.props.size);
|
||||
return { model, name, ext, size };
|
||||
}
|
||||
|
||||
export { getAttachmentType } from '@affine/core/modules/media/utils';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const viewer = style({
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
resize: 'none',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
position: 'absolute',
|
||||
content: '',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
'&:not(.gridding):before': {
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
'&.gridding:before': {
|
||||
opacity: 0.25,
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const viewerContainer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const titlebar = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
height: '52px',
|
||||
padding: '10px 8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
borderTopWidth: '0.5px',
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: cssVarV2('layer/insideBorder/border'),
|
||||
textWrap: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const titlebarChild = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${titlebar} > &`]: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
'&.zoom:not(.show)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const titlebarName = style({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'pre',
|
||||
wordWrap: 'break-word',
|
||||
});
|
||||
+1
-1
@@ -10,8 +10,8 @@ import {
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { download } from './utils';
|
||||
import * as styles from './viewer.css';
|
||||
|
||||
const items = [
|
||||
/*
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
type DefaultOpenProperty,
|
||||
DocPropertiesTable,
|
||||
} from '../../components/doc-properties';
|
||||
import { patchForAttachmentEmbedViews } from '../extensions/attachment-embed-view';
|
||||
import {
|
||||
patchForAudioEmbedView,
|
||||
patchForPDFEmbedView,
|
||||
} from '../extensions/attachment-embed-view';
|
||||
import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
|
||||
import { patchDocModeService } from '../extensions/doc-mode-service';
|
||||
import { patchDocUrlExtensions } from '../extensions/doc-url';
|
||||
@@ -148,6 +151,14 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
featureFlagService.flags.enable_turbo_renderer.$
|
||||
);
|
||||
|
||||
const enablePDFEmbedPreview = useLiveData(
|
||||
featureFlagService.flags.enable_pdf_embed_preview.$
|
||||
);
|
||||
|
||||
const enableAudioBlock = useLiveData(
|
||||
featureFlagService.flags.enable_audio_block.$
|
||||
);
|
||||
|
||||
const patchedSpecs = useMemo(() => {
|
||||
const builder = enableEditorExtension(framework, mode, enableAI);
|
||||
|
||||
@@ -176,8 +187,11 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
].flat()
|
||||
);
|
||||
|
||||
if (featureFlagService.flags.enable_pdf_embed_preview.value) {
|
||||
builder.extend([patchForAttachmentEmbedViews(reactToLit)]);
|
||||
if (enablePDFEmbedPreview) {
|
||||
builder.extend([patchForPDFEmbedView(reactToLit)]);
|
||||
}
|
||||
if (enableAudioBlock) {
|
||||
builder.extend([patchForAudioEmbedView(reactToLit)]);
|
||||
}
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
enableMobileExtension(builder, framework);
|
||||
@@ -202,7 +216,8 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
memberSearchService,
|
||||
publicUserService,
|
||||
enableTurboRenderer,
|
||||
featureFlagService.flags.enable_pdf_embed_preview.value,
|
||||
enablePDFEmbedPreview,
|
||||
enableAudioBlock,
|
||||
]);
|
||||
|
||||
return [
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ElementOrFactory } from '@affine/component';
|
||||
import { AttachmentEmbedPreview } from '@affine/core/components/attachment-viewer/pdf-viewer-embedded';
|
||||
import { AttachmentEmbedPreview } from '@affine/core/blocksuite/attachment-viewer/attachment-embed-preview';
|
||||
import { AttachmentEmbedConfigIdentifier } from '@blocksuite/affine/blocks/attachment';
|
||||
import { Bound } from '@blocksuite/affine/global/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export function patchForAttachmentEmbedViews(
|
||||
export function patchForPDFEmbedView(
|
||||
reactToLit: (
|
||||
element: ElementOrFactory,
|
||||
rerendering?: boolean
|
||||
@@ -34,3 +34,23 @@ export function patchForAttachmentEmbedViews(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function patchForAudioEmbedView(
|
||||
reactToLit: (
|
||||
element: ElementOrFactory,
|
||||
rerendering?: boolean
|
||||
) => TemplateResult
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.override(AttachmentEmbedConfigIdentifier('audio'), () => ({
|
||||
name: 'audio',
|
||||
check: (model, maxFileSize) =>
|
||||
model.props.type.startsWith('audio/') &&
|
||||
model.props.size <= maxFileSize,
|
||||
template: (model, _blobUrl) =>
|
||||
reactToLit(<AttachmentEmbedPreview model={model} />, false),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { FrameworkProvider } from '@toeverything/infra';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import { AIChatBlockSpec } from '../../ai/blocks';
|
||||
import { AITranscriptionBlockSpec } from '../../ai/blocks/ai-chat-block/ai-transcription-block';
|
||||
import { buildDocDisplayMetaExtension } from '../display-meta';
|
||||
import { getFontConfigExtension } from '../font-config';
|
||||
import { patchPeekViewService } from '../peek-view-service';
|
||||
@@ -104,6 +105,7 @@ export function enablePreviewExtension(framework: FrameworkProvider): void {
|
||||
|
||||
_previewExtensions = [
|
||||
...AIChatBlockSpec,
|
||||
...AITranscriptionBlockSpec,
|
||||
fontConfig,
|
||||
getThemeExtension(framework),
|
||||
getPagePreviewThemeExtension(framework),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AttachmentPreviewErrorBoundary } from './error';
|
||||
import { PDFViewerEmbeddedInner } from './pdf-viewer-embedded-inner';
|
||||
import type { AttachmentViewerProps } from './types';
|
||||
import { buildAttachmentProps } from './utils';
|
||||
|
||||
// In Embed view
|
||||
export const AttachmentEmbedPreview = ({ model }: AttachmentViewerProps) => {
|
||||
return (
|
||||
<AttachmentPreviewErrorBoundary key={model.id}>
|
||||
<PDFViewerEmbeddedInner {...buildAttachmentProps(model)} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { type PDF, PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||
import { LoadingSvg } from '@affine/core/modules/pdf/views';
|
||||
import track from '@affine/track';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PDFViewerInner } from './pdf-viewer-inner';
|
||||
import type { PDFViewerProps } from './types';
|
||||
|
||||
function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
|
||||
const state = useLiveData(pdf.state$);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== PDFStatus.Error) return;
|
||||
|
||||
track.$.attachment.$.openPDFRendererFail();
|
||||
}, [state]);
|
||||
|
||||
if (state?.status !== PDFStatus.Opened) {
|
||||
return <PDFLoading />;
|
||||
}
|
||||
|
||||
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model, ...props }: PDFViewerProps) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { pdf, release } = pdfService.get(model);
|
||||
setPdf(pdf);
|
||||
|
||||
return () => {
|
||||
release();
|
||||
};
|
||||
}, [model, pdfService, setPdf]);
|
||||
|
||||
if (!pdf) {
|
||||
return <PDFLoading />;
|
||||
}
|
||||
|
||||
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
||||
}
|
||||
|
||||
const PDFLoading = () => (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<LoadingSvg />
|
||||
</div>
|
||||
);
|
||||
@@ -1,94 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pdfContainer = style({
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
userSelect: 'none',
|
||||
contentVisibility: 'visible',
|
||||
display: 'flex',
|
||||
minHeight: 'fit-content',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const pdfViewer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '12px',
|
||||
overflow: 'hidden',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const pdfPlaceholder = style({
|
||||
position: 'absolute',
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
overflow: 'hidden',
|
||||
height: 'auto',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const pdfControls = style({
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
right: '14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const pdfControlButton = style({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVar('--affine-border-color'),
|
||||
background: cssVar('--affine-white'),
|
||||
});
|
||||
|
||||
export const pdfFooter = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
textWrap: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfFooterItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&.truncate': {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfTitle = style({
|
||||
marginLeft: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfPageCount = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
import { downloadBlob } from '../../utils/resource';
|
||||
import type { PDFViewerProps } from './types';
|
||||
|
||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||
const sourceId = model.props.sourceId;
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
let blob = await doc.blobSync.get(sourceId);
|
||||
|
||||
if (blob) {
|
||||
blob = new Blob([blob], { type: model.props.type });
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function download(model: AttachmentBlockModel) {
|
||||
const blob = await getAttachmentBlob(model);
|
||||
if (!blob) return;
|
||||
|
||||
await downloadBlob(blob, model.props.name);
|
||||
}
|
||||
|
||||
export function buildAttachmentProps(
|
||||
model: AttachmentBlockModel
|
||||
): PDFViewerProps {
|
||||
const pieces = model.props.name.split('.');
|
||||
const ext = pieces.pop() || '';
|
||||
const name = pieces.join('.');
|
||||
const size = filesize(model.props.size);
|
||||
return { model, name, ext, size };
|
||||
}
|
||||
|
||||
export function calculatePageNum(el: HTMLElement, pageCount: number) {
|
||||
const { scrollTop, scrollHeight } = el;
|
||||
const pageHeight = scrollHeight / pageCount;
|
||||
const n = scrollTop / pageHeight;
|
||||
const t = n / pageCount;
|
||||
const index = Math.floor(n + t);
|
||||
const cursor = Math.min(index, pageCount - 1);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
export function fitToPage(
|
||||
viewportInfo: PageSize,
|
||||
actualSize: PageSize,
|
||||
maxSize: PageSize,
|
||||
isThumbnail?: boolean
|
||||
) {
|
||||
const { width: vw, height: vh } = viewportInfo;
|
||||
const { width: w, height: h } = actualSize;
|
||||
const { width: mw, height: mh } = maxSize;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
if (h / w > vh / vw) {
|
||||
height = vh * (h / mh);
|
||||
width = (w / h) * height;
|
||||
} else {
|
||||
const t = isThumbnail ? Math.min(w / h, 1) : w / mw;
|
||||
width = vw * t;
|
||||
height = (h / w) * width;
|
||||
}
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
aspectRatio: width / height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
gap: 12,
|
||||
});
|
||||
|
||||
export const upper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '24px',
|
||||
gap: 12,
|
||||
});
|
||||
|
||||
export const upperLeft = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const upperRight = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const upperRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const nameLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const sizeInfo = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const audioIcon = style({
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const controlButton = style({
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const controls = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
color: cssVarV2('text/primary'),
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px',
|
||||
minWidth: '28px',
|
||||
height: '28px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
':disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
});
|
||||
|
||||
export const progressContainer = style({
|
||||
width: '100%',
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const progressBar = style({
|
||||
width: '100%',
|
||||
height: 12,
|
||||
backgroundColor: cssVarV2('layer/background/tertiary'),
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const progressFill = style({
|
||||
height: '100%',
|
||||
backgroundColor: cssVarV2('icon/fileIconColors/red'),
|
||||
transition: 'width 0.1s linear',
|
||||
});
|
||||
|
||||
export const timeDisplay = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
minWidth: 48,
|
||||
':last-of-type': {
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
export const miniRoot = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const miniNameLabel = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 20,
|
||||
});
|
||||
|
||||
export const miniPlayerContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
});
|
||||
|
||||
export const miniProgressContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 24,
|
||||
});
|
||||
|
||||
export const miniCloseButton = style({
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
display: 'none',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: {
|
||||
[`${miniRoot}:hover &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { CloseIcon, VoiceIcon } from '@blocksuite/icons/rc';
|
||||
import bytes from 'bytes';
|
||||
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
|
||||
|
||||
import * as styles from './audio-player.css';
|
||||
import { AudioWaveform } from './audio-waveform';
|
||||
import { AnimatedPlayIcon } from './lottie/animated-play-icon';
|
||||
|
||||
// Format seconds to mm:ss
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export interface AudioPlayerProps {
|
||||
// Audio metadata
|
||||
name: string;
|
||||
size: number;
|
||||
waveform: number[] | null;
|
||||
// Playback state
|
||||
playbackState: 'idle' | 'playing' | 'paused' | 'stopped';
|
||||
seekTime: number;
|
||||
duration: number;
|
||||
loading?: boolean;
|
||||
|
||||
notesEntry?: ReactNode;
|
||||
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
|
||||
// Playback controls
|
||||
onPlay: MouseEventHandler;
|
||||
onPause: MouseEventHandler;
|
||||
onStop: MouseEventHandler;
|
||||
onSeek: (newTime: number) => void;
|
||||
}
|
||||
|
||||
export const AudioPlayer = ({
|
||||
name,
|
||||
size,
|
||||
playbackState,
|
||||
seekTime,
|
||||
duration,
|
||||
notesEntry,
|
||||
waveform,
|
||||
loading,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClick,
|
||||
}: AudioPlayerProps) => {
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = useCallback(
|
||||
(progress: number) => {
|
||||
const newTime = progress * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[duration, onSeek]
|
||||
);
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
if (playbackState === 'playing') {
|
||||
onPause(e);
|
||||
} else {
|
||||
onPlay(e);
|
||||
}
|
||||
},
|
||||
[loading, playbackState, onPause, onPlay]
|
||||
);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? seekTime / duration : 0;
|
||||
const iconState = loading
|
||||
? 'loading'
|
||||
: playbackState === 'playing'
|
||||
? 'pause'
|
||||
: 'play';
|
||||
|
||||
return (
|
||||
<div className={styles.root} onClick={onClick}>
|
||||
<div className={styles.upper}>
|
||||
<div className={styles.upperLeft}>
|
||||
<div className={styles.upperRow}>
|
||||
<VoiceIcon />
|
||||
<div className={styles.nameLabel}>{name}</div>
|
||||
</div>
|
||||
<div className={styles.upperRow}>
|
||||
<div className={styles.sizeInfo}>{bytes(size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.upperRight}>
|
||||
{notesEntry}
|
||||
<AnimatedPlayIcon
|
||||
onClick={handlePlayToggle}
|
||||
className={styles.controlButton}
|
||||
state={iconState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.timeDisplay}>{formatTime(seekTime)}</div>
|
||||
<AudioWaveform
|
||||
waveform={waveform || []}
|
||||
progress={progressPercentage}
|
||||
onManualSeek={handleProgressClick}
|
||||
/>
|
||||
<div className={styles.timeDisplay}>{formatTime(duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniAudioPlayer = ({
|
||||
name,
|
||||
playbackState,
|
||||
seekTime,
|
||||
duration,
|
||||
waveform,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClick,
|
||||
onStop,
|
||||
}: AudioPlayerProps) => {
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = useCallback(
|
||||
(progress: number) => {
|
||||
const newTime = progress * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[duration, onSeek]
|
||||
);
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (playbackState === 'playing') {
|
||||
onPause(e);
|
||||
} else {
|
||||
onPlay(e);
|
||||
}
|
||||
},
|
||||
[playbackState, onPlay, onPause]
|
||||
);
|
||||
|
||||
const handleRewind = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
onSeek(seekTime - 15);
|
||||
},
|
||||
[seekTime, onSeek]
|
||||
);
|
||||
|
||||
const handleForward = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
onSeek(seekTime + 15);
|
||||
},
|
||||
[seekTime, onSeek]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onStop(e);
|
||||
},
|
||||
[onStop]
|
||||
);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? seekTime / duration : 0;
|
||||
const iconState =
|
||||
playbackState === 'playing'
|
||||
? 'pause'
|
||||
: playbackState === 'paused'
|
||||
? 'play'
|
||||
: 'loading';
|
||||
|
||||
return (
|
||||
<div className={styles.miniRoot} onClick={onClick}>
|
||||
<div className={styles.miniNameLabel}>{name}</div>
|
||||
<div className={styles.miniPlayerContainer}>
|
||||
<div onClick={handleRewind}>-15s</div>
|
||||
<AnimatedPlayIcon
|
||||
onClick={handlePlayToggle}
|
||||
className={styles.controlButton}
|
||||
state={iconState}
|
||||
/>
|
||||
<div onClick={handleForward}>+15s</div>
|
||||
</div>
|
||||
<IconButton
|
||||
className={styles.miniCloseButton}
|
||||
icon={<CloseIcon />}
|
||||
size={16}
|
||||
variant="plain"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className={styles.miniProgressContainer}>
|
||||
<AudioWaveform
|
||||
waveform={waveform || []}
|
||||
progress={progressPercentage}
|
||||
onManualSeek={handleProgressClick}
|
||||
mini
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 2000, // since we have at least 1000 samples, the max width is 2000
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './audio-waveform.css';
|
||||
|
||||
// Helper function to get computed CSS variable value
|
||||
const getCSSVarValue = (element: HTMLElement, varName: AffineThemeKeyV2) => {
|
||||
const style = getComputedStyle(element);
|
||||
const varRef = cssVarV2(varName);
|
||||
const varKey = varRef.match(/var\((.*?)\)/)?.[1];
|
||||
return varKey ? style.getPropertyValue(varKey).trim() : '';
|
||||
};
|
||||
|
||||
interface DrawWaveformOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
container: HTMLElement;
|
||||
waveform: number[];
|
||||
progress: number;
|
||||
mini: boolean;
|
||||
}
|
||||
|
||||
// to avoid the indicator being cut off at the edges
|
||||
const horizontalPadding = 2;
|
||||
|
||||
const drawWaveform = ({
|
||||
canvas,
|
||||
container,
|
||||
waveform,
|
||||
progress,
|
||||
mini,
|
||||
}: DrawWaveformOptions) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
const barWidth = mini ? 0.5 : 1;
|
||||
const gap = 1;
|
||||
|
||||
const availableWidth = rect.width - horizontalPadding * 2;
|
||||
const totalBars = Math.floor(availableWidth / (barWidth + gap));
|
||||
|
||||
// Resample waveform data to match number of bars
|
||||
// We have at least 1000 samples. Totalbars should be less than the total number of samples.
|
||||
const step = waveform.length / totalBars;
|
||||
const bars = Array.from({ length: totalBars }, (_, i) => {
|
||||
const startIdx = Math.floor(i * step);
|
||||
const endIdx = Math.floor((i + 1) * step);
|
||||
const slice = waveform.slice(startIdx, endIdx);
|
||||
return Math.max(slice.reduce((a, b) => a + b, 0) / slice.length, 0.1);
|
||||
});
|
||||
|
||||
// Get colors from CSS variables
|
||||
const unplayedColor = getCSSVarValue(container, 'text/placeholder');
|
||||
const playedColor = getCSSVarValue(
|
||||
container,
|
||||
'block/recordBlock/timelineIndeicator'
|
||||
);
|
||||
const progressIndex = Math.floor(progress * bars.length);
|
||||
|
||||
// Draw bars
|
||||
bars.forEach((value, i) => {
|
||||
const x = horizontalPadding + i * (barWidth + gap);
|
||||
const height = value * rect.height;
|
||||
const y = (rect.height - height) / 2;
|
||||
|
||||
ctx.fillStyle =
|
||||
progress > 0 && i <= progressIndex ? playedColor : unplayedColor;
|
||||
|
||||
// Use roundRect for rounded corners
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, barWidth, height, barWidth / 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Fallback for browsers that don't support roundRect
|
||||
ctx.fillRect(x, y, barWidth, height);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw progress indicator if progress > 0
|
||||
if (progress > 0) {
|
||||
const x = horizontalPadding + progress * availableWidth;
|
||||
ctx.fillStyle = playedColor;
|
||||
|
||||
// Draw the vertical line
|
||||
ctx.fillRect(x - 0.5, 0, 1, rect.height);
|
||||
|
||||
// Draw circles at top and bottom with better positioning
|
||||
const dotRadius = 1.5;
|
||||
ctx.beginPath();
|
||||
// Top dot
|
||||
ctx.arc(x, dotRadius, dotRadius, 0, Math.PI * 2);
|
||||
// Bottom dot
|
||||
ctx.arc(x, rect.height - dotRadius, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// waveform are the amplitude of the audio that sampled at 1000 points
|
||||
// the value is between 0 and 1
|
||||
export const AudioWaveform = ({
|
||||
waveform,
|
||||
progress,
|
||||
onManualSeek,
|
||||
mini = false, // the bar will be 0.5px instead. by default, the bar is 1px
|
||||
}: {
|
||||
waveform: number[];
|
||||
progress: number;
|
||||
onManualSeek: (progress: number) => void;
|
||||
mini?: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Handle click events for seeking
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const availableWidth = rect.width - horizontalPadding * 2;
|
||||
const newProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (x - horizontalPadding) / availableWidth)
|
||||
);
|
||||
onManualSeek(newProgress);
|
||||
e.stopPropagation();
|
||||
},
|
||||
[onManualSeek]
|
||||
);
|
||||
|
||||
// Draw on resize
|
||||
useEffect(() => {
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
drawWaveform({
|
||||
canvas,
|
||||
container,
|
||||
waveform,
|
||||
progress: clamp(progress, 0, 1),
|
||||
mini,
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
draw();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [mini, progress, waveform]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.root}
|
||||
onClick={handleClick}
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress * 100}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './audio-player';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Loading } from '@affine/component';
|
||||
import clsx from 'clsx';
|
||||
import type { LottieRef } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './animated-play-icon.css';
|
||||
import pausetoplay from './pausetoplay.json';
|
||||
import playtopause from './playtopause.json';
|
||||
|
||||
export interface AnimatedPlayIconProps {
|
||||
state: 'play' | 'pause' | 'loading';
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const buildAnimatedLottieIcon = (data: Record<string, unknown>) => {
|
||||
const Component = ({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const lottieRef: LottieRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (lottieRef.current) {
|
||||
const lottie = lottieRef.current;
|
||||
lottie.setSpeed(2);
|
||||
lottie.play();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Lottie
|
||||
onClick={onClick}
|
||||
lottieRef={lottieRef}
|
||||
className={clsx(styles.root, className)}
|
||||
animationData={data}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return Component;
|
||||
};
|
||||
|
||||
const PlayIcon = buildAnimatedLottieIcon(playtopause);
|
||||
const PauseIcon = buildAnimatedLottieIcon(pausetoplay);
|
||||
|
||||
export const AnimatedPlayIcon = ({
|
||||
state,
|
||||
className,
|
||||
onClick,
|
||||
}: AnimatedPlayIconProps) => {
|
||||
if (state === 'loading') {
|
||||
return <Loading size={40} />;
|
||||
}
|
||||
const Icon = state === 'play' ? PlayIcon : PauseIcon;
|
||||
return <Icon onClick={onClick} className={className} />;
|
||||
};
|
||||
@@ -0,0 +1,715 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 60,
|
||||
"op": 103,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "pause to play",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [1] },
|
||||
"t": 60,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [0] },
|
||||
"t": 90,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.167], "y": [0] },
|
||||
"t": 120,
|
||||
"s": [0]
|
||||
},
|
||||
{ "t": 142, "s": [100] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [1, 1, 0] },
|
||||
"t": 60,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 120,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0.782, 0.44],
|
||||
[0, 0],
|
||||
[0.504, 0.225],
|
||||
[0.525, -0.059],
|
||||
[0.453, -0.629],
|
||||
[0.051, -0.554],
|
||||
[0, -0.867],
|
||||
[0, 0],
|
||||
[-0.05, -0.55],
|
||||
[-0.309, -0.428],
|
||||
[-0.77, -0.087],
|
||||
[-0.508, 0.227],
|
||||
[-0.756, 0.425],
|
||||
[0, 0],
|
||||
[-0.468, 0.323],
|
||||
[-0.221, 0.491],
|
||||
[0.324, 0.718],
|
||||
[0.47, 0.324]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[-0.756, -0.425],
|
||||
[-0.508, -0.227],
|
||||
[-0.77, 0.087],
|
||||
[-0.309, 0.428],
|
||||
[-0.05, 0.55],
|
||||
[0, 0],
|
||||
[0, 0.867],
|
||||
[0.051, 0.554],
|
||||
[0.453, 0.629],
|
||||
[0.525, 0.059],
|
||||
[0.504, -0.225],
|
||||
[0, 0],
|
||||
[0.782, -0.44],
|
||||
[0.47, -0.324],
|
||||
[0.324, -0.718],
|
||||
[-0.221, -0.491],
|
||||
[-0.468, -0.323]
|
||||
],
|
||||
"v": [
|
||||
[4.482, -3.424],
|
||||
[-1.857, -6.99],
|
||||
[-3.729, -7.985],
|
||||
[-5.269, -8.313],
|
||||
[-7.19, -7.189],
|
||||
[-7.66, -5.686],
|
||||
[-7.71, -3.566],
|
||||
[-7.71, 3.566],
|
||||
[-7.66, 5.686],
|
||||
[-7.19, 7.189],
|
||||
[-5.269, 8.313],
|
||||
[-3.729, 7.985],
|
||||
[-1.857, 6.99],
|
||||
[4.482, 3.424],
|
||||
[6.365, 2.305],
|
||||
[7.467, 1.13],
|
||||
[7.467, -1.13],
|
||||
[6.365, -2.305]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "Icon (Stroke)",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Union",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 60,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 83,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6], "y": [1] },
|
||||
"o": { "x": [0.32], "y": [0.94] },
|
||||
"t": 120,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 150, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 60,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 83,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
|
||||
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
|
||||
"t": 99,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{ "t": 150, "s": [39, 39, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[-0.849, 0],
|
||||
[0, 0.849]
|
||||
],
|
||||
"o": [
|
||||
[0, -0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[0.849, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-2.563, -6.152],
|
||||
[-4.101, -7.69],
|
||||
[-5.639, -6.152],
|
||||
[-5.639, 6.152],
|
||||
[-4.101, 7.69],
|
||||
[-2.563, 6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 1",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ind": 2,
|
||||
"ty": "sh",
|
||||
"ix": 3,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, 0.849],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, -0.849]
|
||||
],
|
||||
"v": [
|
||||
[4.101, -7.69],
|
||||
[5.639, -6.152],
|
||||
[5.639, 6.152],
|
||||
[4.101, 7.69],
|
||||
[2.563, 6.152],
|
||||
[2.563, -6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 2",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 2",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "Union",
|
||||
"np": 5,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 133,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 145, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 145, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 120,
|
||||
"op": 161,
|
||||
"st": 60,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 73,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 85, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 60,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 85, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 101,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 120,
|
||||
"op": 159,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "playtopause",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [1] },
|
||||
"t": 60,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [0] },
|
||||
"t": 90,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.167], "y": [0] },
|
||||
"t": 120,
|
||||
"s": [0]
|
||||
},
|
||||
{ "t": 142, "s": [100] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [1, 1, 0] },
|
||||
"t": 60,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 120,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0.782, 0.44],
|
||||
[0, 0],
|
||||
[0.504, 0.225],
|
||||
[0.525, -0.059],
|
||||
[0.453, -0.629],
|
||||
[0.051, -0.554],
|
||||
[0, -0.867],
|
||||
[0, 0],
|
||||
[-0.05, -0.55],
|
||||
[-0.309, -0.428],
|
||||
[-0.77, -0.087],
|
||||
[-0.508, 0.227],
|
||||
[-0.756, 0.425],
|
||||
[0, 0],
|
||||
[-0.468, 0.323],
|
||||
[-0.221, 0.491],
|
||||
[0.324, 0.718],
|
||||
[0.47, 0.324]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[-0.756, -0.425],
|
||||
[-0.508, -0.227],
|
||||
[-0.77, 0.087],
|
||||
[-0.309, 0.428],
|
||||
[-0.05, 0.55],
|
||||
[0, 0],
|
||||
[0, 0.867],
|
||||
[0.051, 0.554],
|
||||
[0.453, 0.629],
|
||||
[0.525, 0.059],
|
||||
[0.504, -0.225],
|
||||
[0, 0],
|
||||
[0.782, -0.44],
|
||||
[0.47, -0.324],
|
||||
[0.324, -0.718],
|
||||
[-0.221, -0.491],
|
||||
[-0.468, -0.323]
|
||||
],
|
||||
"v": [
|
||||
[4.482, -3.424],
|
||||
[-1.857, -6.99],
|
||||
[-3.729, -7.985],
|
||||
[-5.269, -8.313],
|
||||
[-7.19, -7.189],
|
||||
[-7.66, -5.686],
|
||||
[-7.71, -3.566],
|
||||
[-7.71, 3.566],
|
||||
[-7.66, 5.686],
|
||||
[-7.19, 7.189],
|
||||
[-5.269, 8.313],
|
||||
[-3.729, 7.985],
|
||||
[-1.857, 6.99],
|
||||
[4.482, 3.424],
|
||||
[6.365, 2.305],
|
||||
[7.467, 1.13],
|
||||
[7.467, -1.13],
|
||||
[6.365, -2.305]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "Icon (Stroke)",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Union",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 60,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 83,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6], "y": [1] },
|
||||
"o": { "x": [0.32], "y": [0.94] },
|
||||
"t": 120,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 150, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 60,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 83,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
|
||||
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
|
||||
"t": 99,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{ "t": 150, "s": [39, 39, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[-0.849, 0],
|
||||
[0, 0.849]
|
||||
],
|
||||
"o": [
|
||||
[0, -0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[0.849, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-2.563, -6.152],
|
||||
[-4.101, -7.69],
|
||||
[-5.639, -6.152],
|
||||
[-5.639, 6.152],
|
||||
[-4.101, 7.69],
|
||||
[-2.563, 6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 1",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ind": 2,
|
||||
"ty": "sh",
|
||||
"ix": 3,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, 0.849],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, -0.849]
|
||||
],
|
||||
"v": [
|
||||
[4.101, -7.69],
|
||||
[5.639, -6.152],
|
||||
[5.639, 6.152],
|
||||
[4.101, 7.69],
|
||||
[2.563, 6.152],
|
||||
[2.563, -6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 2",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 2",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "Union",
|
||||
"np": 5,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 133,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 145, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 145, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 120,
|
||||
"op": 161,
|
||||
"st": 60,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 73,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 85, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 60,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 85, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 101,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "填充 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AudioMediaPlaybackState } from '@affine/core/modules/media/entities/audio-media';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const EPSILON = 0.02;
|
||||
|
||||
export const useSeekTime = (
|
||||
playbackState:
|
||||
| {
|
||||
state: AudioMediaPlaybackState;
|
||||
seekOffset: number;
|
||||
updateTime: number;
|
||||
}
|
||||
| undefined
|
||||
| null,
|
||||
duration?: number
|
||||
) => {
|
||||
const [seekTime, setSeekTime] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!playbackState) {
|
||||
return;
|
||||
}
|
||||
const updateSeekTime = () => {
|
||||
if (playbackState) {
|
||||
const timeElapsed =
|
||||
playbackState.state === 'playing'
|
||||
? (Date.now() - playbackState.updateTime) / 1000
|
||||
: 0;
|
||||
// if timeElapsed + playbackState.seekOffset is closed to duration,
|
||||
// set seekTime to duration
|
||||
// this is to avoid the seek time being set to a value that is not exactly the same as the duration
|
||||
// at the end of the audio
|
||||
if (
|
||||
duration &&
|
||||
Math.abs(timeElapsed + playbackState.seekOffset - duration) < EPSILON
|
||||
) {
|
||||
setSeekTime(duration);
|
||||
} else {
|
||||
setSeekTime(timeElapsed + playbackState.seekOffset);
|
||||
}
|
||||
}
|
||||
};
|
||||
updateSeekTime();
|
||||
const interval = setInterval(updateSeekTime, 16.67);
|
||||
return () => clearInterval(interval);
|
||||
}, [duration, playbackState]);
|
||||
|
||||
return clamp(seekTime, 0, duration ?? Number.MAX_SAFE_INTEGER);
|
||||
};
|
||||
@@ -99,3 +99,7 @@ export const freeTag = style({
|
||||
color: cssVar('pureWhite'),
|
||||
background: cssVar('primaryColor'),
|
||||
});
|
||||
|
||||
export const bottomContainer = style({
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { memo, useCallback } from 'react';
|
||||
import { WorkbenchService } from '../../modules/workbench';
|
||||
import { WorkspaceNavigator } from '../workspace-selector';
|
||||
import {
|
||||
bottomContainer,
|
||||
quickSearch,
|
||||
quickSearchAndNewPage,
|
||||
workspaceAndUserWrapper,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
} from './index.css';
|
||||
import { AppSidebarJournalButton } from './journal-button';
|
||||
import { NotificationButton } from './notification-button';
|
||||
import { SidebarAudioPlayer } from './sidebar-audio-player';
|
||||
import { TemplateDocEntrance } from './template-doc-entrance';
|
||||
import { TrashButton } from './trash-button';
|
||||
import { UpdaterButton } from './updater-button';
|
||||
@@ -204,7 +206,8 @@ export const RootAppSidebar = memo((): ReactElement => {
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</SidebarScrollableContainer>
|
||||
<SidebarContainer>
|
||||
<SidebarContainer className={bottomContainer}>
|
||||
<SidebarAudioPlayer />
|
||||
{BUILD_CONFIG.isElectron ? <UpdaterButton /> : <AppDownloadButton />}
|
||||
</SidebarContainer>
|
||||
</AppSidebar>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AudioMediaManagerService } from '@affine/core/modules/media';
|
||||
import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block';
|
||||
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { combineLatest, debounceTime, map, of } from 'rxjs';
|
||||
|
||||
import { MiniAudioPlayer } from '../audio-player';
|
||||
import { useSeekTime } from '../audio-player/use-seek-time';
|
||||
import * as styles from './sidebar-audio-player.css';
|
||||
|
||||
export const SidebarAudioPlayer = () => {
|
||||
const audioMediaManagerService = useService(AudioMediaManagerService);
|
||||
const audioAttachmentService = useService(AudioAttachmentService);
|
||||
const playbackState = useLiveData(audioMediaManagerService.playbackState$);
|
||||
const playbackStats = useLiveData(audioMediaManagerService.playbackStats$);
|
||||
|
||||
const [audioAttachmentBlockEntity, setAudioAttachmentBlockEntity] =
|
||||
useState<AudioAttachmentBlock | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playbackStats?.key) {
|
||||
return;
|
||||
}
|
||||
const objRef = audioAttachmentService.get(playbackStats.key);
|
||||
if (objRef) {
|
||||
setAudioAttachmentBlockEntity(objRef.obj);
|
||||
return () => {
|
||||
objRef.release();
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [playbackStats, audioAttachmentService]);
|
||||
|
||||
const isSameTab = useMemo(() => {
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
return playbackStats?.tabId === audioMediaManagerService.currentTabId;
|
||||
}
|
||||
return true;
|
||||
}, [playbackStats, audioMediaManagerService.currentTabId]);
|
||||
|
||||
const shouldShow = useLiveData(
|
||||
useMemo(() => {
|
||||
return LiveData.from(
|
||||
combineLatest([
|
||||
audioAttachmentBlockEntity?.rendering$ ?? of(false),
|
||||
audioMediaManagerService.playbackState$,
|
||||
]).pipe(
|
||||
map(([v, state]) => {
|
||||
if (isSameTab && v) {
|
||||
return false;
|
||||
}
|
||||
if (state?.state === 'stopped') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
debounceTime(50)
|
||||
),
|
||||
false
|
||||
);
|
||||
}, [
|
||||
audioAttachmentBlockEntity,
|
||||
audioMediaManagerService.playbackState$,
|
||||
isSameTab,
|
||||
])
|
||||
);
|
||||
|
||||
const seekTime = useSeekTime(playbackState, playbackStats?.duration);
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
audioMediaManagerService.play();
|
||||
},
|
||||
[audioMediaManagerService]
|
||||
);
|
||||
|
||||
const handlePause = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
audioMediaManagerService.pause();
|
||||
},
|
||||
[audioMediaManagerService]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
audioMediaManagerService.stop();
|
||||
},
|
||||
[audioMediaManagerService]
|
||||
);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(newTime: number) => {
|
||||
audioMediaManagerService.seekTo(newTime);
|
||||
},
|
||||
[audioMediaManagerService]
|
||||
);
|
||||
|
||||
const handlePlayerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!playbackStats) {
|
||||
return;
|
||||
}
|
||||
// jump to the audio attachment
|
||||
audioMediaManagerService.focusAudioMedia(
|
||||
playbackStats.key,
|
||||
playbackStats.tabId
|
||||
);
|
||||
},
|
||||
[playbackStats, audioMediaManagerService]
|
||||
);
|
||||
|
||||
if (!shouldShow || !playbackState || !playbackStats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} onClick={handlePlayerClick}>
|
||||
<MiniAudioPlayer
|
||||
playbackState={playbackState.state}
|
||||
name={playbackStats.name}
|
||||
size={playbackStats.size}
|
||||
duration={playbackStats.duration}
|
||||
seekTime={seekTime}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
waveform={playbackStats.waveform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { AttachmentViewer } from '@affine/core/blocksuite/attachment-viewer';
|
||||
import { type Doc, DocsService } from '@affine/core/modules/doc';
|
||||
import { type AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
|
||||
import { type ReactElement, useLayoutEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { AttachmentViewerView } from '../../../../components/attachment-viewer';
|
||||
import { ViewIcon, ViewTitle } from '../../../../modules/workbench';
|
||||
import { PageNotFound } from '../../404';
|
||||
import * as styles from './index.css';
|
||||
@@ -71,7 +71,7 @@ export const AttachmentPage = ({
|
||||
<ViewIcon
|
||||
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
|
||||
/>
|
||||
<AttachmentViewerView model={model} />
|
||||
<AttachmentViewer model={model} />
|
||||
</FrameworkScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,28 @@ import { type PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export function SidebarContainer({ children }: PropsWithChildren) {
|
||||
return <div className={clsx([styles.baseContainer])}>{children}</div>;
|
||||
interface SidebarContainerProps extends PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||
export function SidebarContainer({
|
||||
children,
|
||||
className,
|
||||
}: SidebarContainerProps) {
|
||||
return (
|
||||
<div className={clsx([styles.baseContainer, className])}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarScrollableContainer({
|
||||
children,
|
||||
className,
|
||||
}: SidebarContainerProps) {
|
||||
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||
return (
|
||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||
<ScrollArea.Root
|
||||
className={clsx([styles.scrollableContainerRoot, className])}
|
||||
>
|
||||
<div
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={styles.scrollTopBorder}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notify } from '@affine/component';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { AuthService, DefaultServerService, ServersService } from '../../cloud';
|
||||
@@ -32,6 +33,25 @@ export class DesktopApiService extends Service {
|
||||
return this.api.sharedStorage;
|
||||
}
|
||||
|
||||
async showTab(tabId: string, to?: To) {
|
||||
if (to) {
|
||||
const url = new URL(to.toString());
|
||||
const tabs = await this.api.handler.ui.getTabViewsMeta();
|
||||
const tab = tabs.workbenches.find(t => t.id === tabId);
|
||||
if (tab) {
|
||||
const basename = tab.basename;
|
||||
if (url.pathname.startsWith(basename)) {
|
||||
const pathname = url.pathname.slice(basename.length);
|
||||
await this.api.handler.ui.tabGoTo(
|
||||
tabId,
|
||||
pathname + url.search + url.hash
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.api.handler.ui.showTab(tabId);
|
||||
}
|
||||
|
||||
private setupStartListener() {
|
||||
this.setupCommonUIEvents();
|
||||
this.setupAuthRequestEvent();
|
||||
|
||||
@@ -243,6 +243,15 @@ export const AFFINE_FLAGS = {
|
||||
configurable: !isMobile,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_audio_block: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.description',
|
||||
configurable: !isMobile,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_editor_rtl: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
|
||||
@@ -30,6 +30,7 @@ import { configureImportTemplateModule } from './import-template';
|
||||
import { configureIntegrationModule } from './integration';
|
||||
import { configureJournalModule } from './journal';
|
||||
import { configureLifecycleModule } from './lifecycle';
|
||||
import { configureMediaModule } from './media';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configureNotificationModule } from './notification';
|
||||
import { configureOpenInApp } from './open-in-app';
|
||||
@@ -103,6 +104,7 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureAIButtonModule(framework);
|
||||
configureTemplateDocModule(framework);
|
||||
configureBlobManagementModule(framework);
|
||||
configureMediaModule(framework);
|
||||
configureImportClipperModule(framework);
|
||||
configureNotificationModule(framework);
|
||||
configureIntegrationModule(framework);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Cross-Tab Audio State Synchronization
|
||||
|
||||
## How Cross-Tab Audio Synchronization Works
|
||||
|
||||
1. **Global State**:
|
||||
|
||||
- Shared between all tabs via Electron's global state or browser storage
|
||||
- Contains `PlaybackState` and `MediaStats` objects
|
||||
- Each state update includes a timestamp (`updateTime`) to track recency
|
||||
|
||||
2. **Tab 1 - Playing Audio**:
|
||||
|
||||
- User initiates playback in Tab 1
|
||||
- `AudioMediaManagerService` updates global state with new playback info
|
||||
- Global state includes the tab ID that initiated playback
|
||||
|
||||
3. **Tab 2 - Responding to Changes**:
|
||||
|
||||
- Observes changes to global state via `observeGlobalPlaybackState`
|
||||
- Detects that audio is playing in another tab (different tabId)
|
||||
- Automatically stops any playing audio in Tab 2
|
||||
- Does not attempt to play the audio from Tab 1
|
||||
|
||||
4. **State Synchronization**:
|
||||
|
||||
- All state changes include `updateTime` to prevent race conditions
|
||||
- `distinctUntilChanged` ensures only meaningful state changes trigger updates
|
||||
- `skipUpdate` parameter prevents circular update loops
|
||||
|
||||
5. **Exclusive Playback**:
|
||||
- `ensureExclusivePlayback` ensures only one audio plays at a time
|
||||
- When a tab starts playing, all other tabs stop their playback
|
||||
- Global state maintains a single source of truth
|
||||
|
||||
This architecture ensures that audio playback is synchronized across tabs, with only one audio playing at any time, while maintaining a consistent user experience.
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
type AttachmentBlockModel,
|
||||
TranscriptionBlockFlavour,
|
||||
type TranscriptionBlockModel,
|
||||
} from '@blocksuite/affine/model';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine/shared/types';
|
||||
import { type DeltaInsert, Text } from '@blocksuite/affine/store';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import type { AudioMediaManagerService } from '../services/audio-media-manager';
|
||||
import type { AudioMedia } from './audio-media';
|
||||
|
||||
export interface TranscriptionResult {
|
||||
title: string;
|
||||
summary: string;
|
||||
segments: {
|
||||
speaker: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
transcription: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// BlockSuiteError: yText must not contain "\r" because it will break the range synchronization
|
||||
function sanitizeText(text: string) {
|
||||
return text.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
|
||||
private readonly refCount$ = new LiveData<number>(0);
|
||||
readonly audioMedia: AudioMedia;
|
||||
constructor(
|
||||
public readonly audioMediaManagerService: AudioMediaManagerService
|
||||
) {
|
||||
super();
|
||||
const mediaRef = audioMediaManagerService.ensureMediaEntity(this.props);
|
||||
this.audioMedia = mediaRef.media;
|
||||
this.disposables.push(() => mediaRef.release());
|
||||
}
|
||||
|
||||
// rendering means the attachment is visible in the editor
|
||||
rendering$ = this.refCount$.map(refCount => refCount > 0);
|
||||
expanded$ = new LiveData<boolean>(true);
|
||||
transcribing$ = new LiveData<boolean>(false);
|
||||
transcriptionError$ = new LiveData<Error | null>(null);
|
||||
transcribed$ = LiveData.computed(get => {
|
||||
const transcriptionBlock = get(this.transcriptionBlock$);
|
||||
if (!transcriptionBlock) {
|
||||
return null;
|
||||
}
|
||||
const childMap = get(LiveData.fromSignal(transcriptionBlock.childMap));
|
||||
return childMap.size > 0;
|
||||
});
|
||||
|
||||
transcribe = effect(
|
||||
switchMap(() =>
|
||||
fromPromise(this.doTranscribe()).pipe(
|
||||
mergeMap(result => {
|
||||
// attach transcription result to the block
|
||||
this.fillTranscriptionResult(result);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.transcriptionError$),
|
||||
onStart(() => this.transcribing$.setValue(true)),
|
||||
onComplete(() => this.transcribing$.setValue(false))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
readonly transcriptionBlock$ = LiveData.fromSignal(
|
||||
computed(() => {
|
||||
// find the last transcription block
|
||||
for (const key of [...this.props.childMap.value.keys()].reverse()) {
|
||||
const block = this.props.doc.getBlock$(key);
|
||||
if (block?.flavour === TranscriptionBlockFlavour) {
|
||||
return block.model as unknown as TranscriptionBlockModel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
// TODO: use real implementation
|
||||
private readonly doTranscribe = async (): Promise<TranscriptionResult> => {
|
||||
try {
|
||||
const buffer = await this.audioMedia.getBuffer();
|
||||
if (!buffer) {
|
||||
throw new Error('No audio buffer available');
|
||||
}
|
||||
|
||||
// Send binary audio data directly
|
||||
const blob = new Blob([buffer], { type: 'audio/wav' }); // adjust mime type if needed
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob);
|
||||
|
||||
const response = await fetch('http://localhost:6544/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Transcription failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.transcription;
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly fillTranscriptionResult = (result: TranscriptionResult) => {
|
||||
this.props.props.caption = result.title;
|
||||
// todo: add transcription block schema etc.
|
||||
const transcriptionBlockId = this.props.doc.addBlock(
|
||||
'affine:transcription',
|
||||
{
|
||||
transcription: result,
|
||||
},
|
||||
this.props.id
|
||||
);
|
||||
|
||||
const calloutId = this.props.doc.addBlock(
|
||||
'affine:callout',
|
||||
{
|
||||
emoji: '💬',
|
||||
},
|
||||
transcriptionBlockId
|
||||
);
|
||||
|
||||
// todo: refactor
|
||||
const spearkerToColors = new Map<string, string>();
|
||||
for (const segment of result.segments) {
|
||||
let color = spearkerToColors.get(segment.speaker);
|
||||
const colorOptions = [
|
||||
cssVarV2.text.highlight.fg.red,
|
||||
cssVarV2.text.highlight.fg.green,
|
||||
cssVarV2.text.highlight.fg.blue,
|
||||
cssVarV2.text.highlight.fg.yellow,
|
||||
cssVarV2.text.highlight.fg.purple,
|
||||
cssVarV2.text.highlight.fg.orange,
|
||||
cssVarV2.text.highlight.fg.teal,
|
||||
cssVarV2.text.highlight.fg.grey,
|
||||
cssVarV2.text.highlight.fg.magenta,
|
||||
];
|
||||
if (!color) {
|
||||
color = colorOptions[spearkerToColors.size % colorOptions.length];
|
||||
spearkerToColors.set(segment.speaker, color);
|
||||
}
|
||||
const deltaInserts: DeltaInsert<AffineTextAttributes>[] = [
|
||||
{
|
||||
insert: sanitizeText(segment.start_time + ' ' + segment.speaker),
|
||||
attributes: {
|
||||
color,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ': ' + sanitizeText(segment.transcription),
|
||||
},
|
||||
];
|
||||
this.props.doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text(deltaInserts),
|
||||
},
|
||||
calloutId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
mount() {
|
||||
this.refCount$.setValue(this.refCount$.value + 1);
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.refCount$.setValue(this.refCount$.value - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
type MediaStats,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
const logger = new DebugLogger('AttachmentBlockMedia');
|
||||
|
||||
/**
|
||||
* Interface for audio sources that can be played by AudioMedia
|
||||
*/
|
||||
export interface AudioSource {
|
||||
/**
|
||||
* The source ID (blob id) for the blob
|
||||
*/
|
||||
blobId: string;
|
||||
|
||||
/**
|
||||
* The metadata of the audio source Web Media Session API
|
||||
*/
|
||||
metadata: MediaMetadata;
|
||||
}
|
||||
|
||||
export type AudioMediaPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped';
|
||||
|
||||
export interface AudioMediaSyncState {
|
||||
state: AudioMediaPlaybackState;
|
||||
seekOffset: number;
|
||||
updateTime: number; // the time when the playback state is updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio media entity.
|
||||
* Controls the playback of audio media.
|
||||
*/
|
||||
export class AudioMedia extends Entity<AudioSource> {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
|
||||
// Create audio element
|
||||
this.audioElement = new Audio();
|
||||
|
||||
// Set up event listeners for the audio element
|
||||
const onPlay = () => {
|
||||
this.updatePlaybackState(
|
||||
'playing',
|
||||
this.playbackState$.getValue().seekOffset,
|
||||
Date.now()
|
||||
);
|
||||
this.updateMediaSessionPlaybackState('playing');
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
this.pause();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
this.pause();
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
this.audioElement.addEventListener('play', onPlay);
|
||||
this.audioElement.addEventListener('pause', onPause);
|
||||
this.audioElement.addEventListener('ended', onEnded);
|
||||
|
||||
this.revalidateBuffer();
|
||||
|
||||
this.disposables.push(() => {
|
||||
// Clean up audio resources before calling super.dispose
|
||||
try {
|
||||
// Remove event listeners
|
||||
this.audioElement.removeEventListener('play', onPlay);
|
||||
this.audioElement.removeEventListener('pause', onPause);
|
||||
this.audioElement.removeEventListener('ended', onEnded);
|
||||
|
||||
// Revoke blob URL if it exists
|
||||
if (
|
||||
this.audioElement.src &&
|
||||
this.audioElement.src.startsWith('blob:')
|
||||
) {
|
||||
URL.revokeObjectURL(this.audioElement.src);
|
||||
}
|
||||
|
||||
this.audioElement.pause();
|
||||
this.audioElement.src = '';
|
||||
this.audioElement.load(); // Reset and release resources
|
||||
|
||||
// Clean up media session
|
||||
this.cleanupMediaSession();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
logger.warn('Error cleaning up audio element during disposal', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loading$ = new LiveData(false);
|
||||
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
|
||||
*/
|
||||
playbackState$ = new LiveData<AudioMediaSyncState>({
|
||||
state: 'idle',
|
||||
seekOffset: 0,
|
||||
updateTime: 0,
|
||||
});
|
||||
|
||||
stats$ = LiveData.computed(get => {
|
||||
const waveform = get(this.waveform$) ?? [];
|
||||
const duration = get(this.duration$) ?? 0;
|
||||
return { waveform, duration };
|
||||
});
|
||||
|
||||
private readonly audioElement: HTMLAudioElement;
|
||||
|
||||
private updatePlaybackState(
|
||||
state: AudioMediaPlaybackState,
|
||||
seekOffset: number,
|
||||
updateTime = Date.now()
|
||||
) {
|
||||
this.playbackState$.setValue({
|
||||
state,
|
||||
seekOffset,
|
||||
updateTime,
|
||||
});
|
||||
}
|
||||
|
||||
async getBuffer() {
|
||||
const blobId = this.props.blobId;
|
||||
if (!blobId) {
|
||||
throw new Error('Audio source ID not found');
|
||||
}
|
||||
|
||||
const blobRecord =
|
||||
await this.workspaceService.workspace.engine.blob.get(blobId);
|
||||
|
||||
if (!blobRecord) {
|
||||
throw new Error('Audio blob not found');
|
||||
}
|
||||
|
||||
return blobRecord.data;
|
||||
}
|
||||
|
||||
private async loadAudioBuffer() {
|
||||
const uint8Array = await this.getBuffer();
|
||||
|
||||
// Create a blob from the uint8Array
|
||||
const blob = new Blob([uint8Array]);
|
||||
|
||||
const startTime = performance.now();
|
||||
// calculating audio stats is expensive. Maybe persist the result in cache?
|
||||
const stats = await this.calcuateStatsFromBuffer(blob);
|
||||
logger.debug(
|
||||
`Calculate audio stats time: ${performance.now() - startTime}ms`
|
||||
);
|
||||
return {
|
||||
blob,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
readonly revalidateBuffer = effect(
|
||||
switchMap(() => {
|
||||
return fromPromise(async () => {
|
||||
return this.loadAudioBuffer();
|
||||
}).pipe(
|
||||
mergeMap(({ blob, duration, waveform }) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.duration$.setValue(duration);
|
||||
// Set the audio element source
|
||||
this.audioElement.src = url;
|
||||
this.waveform$.setValue(waveform);
|
||||
// If the media is playing, resume the playback
|
||||
if (this.playbackState$.getValue().state === 'playing') {
|
||||
this.play(true);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => this.loading$.setValue(true)),
|
||||
onComplete(() => {
|
||||
this.loading$.setValue(false);
|
||||
}),
|
||||
catchErrorInto(this.loadError$)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
get waveform() {
|
||||
return this.waveform$.getValue();
|
||||
}
|
||||
|
||||
getStats(): Pick<MediaStats, 'duration' | 'waveform'> {
|
||||
return this.stats$.getValue();
|
||||
}
|
||||
|
||||
private setupMediaSession() {
|
||||
if (!('mediaSession' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up media session action handlers
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
this.play();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
this.pause();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('stop', () => {
|
||||
this.stop();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', details => {
|
||||
if (details.seekTime !== undefined) {
|
||||
this.seekTo(details.seekTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateMediaSessionMetadata() {
|
||||
if (!('mediaSession' in navigator) || !this.props.metadata) {
|
||||
return;
|
||||
}
|
||||
navigator.mediaSession.metadata = this.props.metadata;
|
||||
}
|
||||
|
||||
private updateMediaSessionPositionState(seekTime: number) {
|
||||
if (!('mediaSession' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = this.audioElement.duration || 0;
|
||||
if (duration > 0) {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
position: seekTime,
|
||||
playbackRate: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateMediaSessionPlaybackState(state: AudioMediaPlaybackState) {
|
||||
if (!('mediaSession' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaSession.playbackState =
|
||||
state === 'playing' ? 'playing' : 'paused';
|
||||
this.updateMediaSessionMetadata();
|
||||
}
|
||||
|
||||
private cleanupMediaSession() {
|
||||
if (!('mediaSession' in navigator)) {
|
||||
return;
|
||||
}
|
||||
navigator.mediaSession.metadata = null;
|
||||
// Reset all action handlers
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('stop', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
}
|
||||
|
||||
play(skipUpdate?: boolean) {
|
||||
if (!this.audioElement.src) {
|
||||
return;
|
||||
}
|
||||
const duration = this.audioElement.duration || 0;
|
||||
const currentSeek = this.getCurrentSeekPosition();
|
||||
if (!skipUpdate || currentSeek >= duration) {
|
||||
// If we're at the end of the track, reset the seek position to 0
|
||||
if (currentSeek >= duration) {
|
||||
this.audioElement.currentTime = 0;
|
||||
this.updatePlaybackState('playing', 0);
|
||||
} else {
|
||||
this.updatePlaybackState(
|
||||
'playing',
|
||||
this.playbackState$.getValue().seekOffset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Play the audio element
|
||||
this.audioElement.play().catch(error => {
|
||||
logger.error('Error playing audio:', error);
|
||||
this.updatePlaybackState('paused', this.audioElement.currentTime);
|
||||
});
|
||||
|
||||
// Set up media session when playback starts
|
||||
this.setupMediaSession();
|
||||
this.updateMediaSessionPositionState(this.audioElement.currentTime);
|
||||
this.updateMediaSessionPlaybackState('playing');
|
||||
}
|
||||
|
||||
pause(skipUpdate?: boolean) {
|
||||
if (!this.audioElement.src) {
|
||||
return;
|
||||
}
|
||||
if (!skipUpdate) {
|
||||
// Update startSeekOffset before pausing
|
||||
this.updatePlaybackState('paused', this.audioElement.currentTime);
|
||||
}
|
||||
|
||||
// Pause the audio element
|
||||
this.audioElement.pause();
|
||||
this.updateMediaSessionPlaybackState('paused');
|
||||
}
|
||||
|
||||
stop(skipUpdate?: boolean) {
|
||||
if (!this.audioElement.src) {
|
||||
return;
|
||||
}
|
||||
// Pause the audio element and reset position
|
||||
this.audioElement.pause();
|
||||
this.audioElement.currentTime = 0;
|
||||
|
||||
if (!skipUpdate) {
|
||||
// Reset the seek position
|
||||
this.updatePlaybackState('stopped', 0);
|
||||
}
|
||||
|
||||
this.updateMediaSessionPlaybackState('stopped');
|
||||
|
||||
// Clean up media session when stopped
|
||||
this.cleanupMediaSession();
|
||||
}
|
||||
|
||||
// Add a seekTo method to handle seeking
|
||||
seekTo(seekTime: number, skipUpdate?: boolean) {
|
||||
if (!this.audioElement.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = this.audioElement.duration;
|
||||
// Clamp the time value between 0 and duration
|
||||
const clampedTime = clamp(0, seekTime, duration || 0);
|
||||
|
||||
// Update the audio element's current time
|
||||
this.audioElement.currentTime = clampedTime;
|
||||
|
||||
// Update startSeekOffset and startTime if playing
|
||||
const currentState = this.playbackState$.getValue();
|
||||
if (!skipUpdate) {
|
||||
this.updatePlaybackState(currentState.state, clampedTime);
|
||||
}
|
||||
this.updateMediaSessionPositionState(clampedTime);
|
||||
}
|
||||
|
||||
syncState(state: AudioMediaSyncState) {
|
||||
const currentState = this.playbackState$.getValue();
|
||||
if (state.updateTime <= currentState.updateTime) {
|
||||
return;
|
||||
}
|
||||
this.updatePlaybackState(state.state, state.seekOffset, state.updateTime);
|
||||
if (state.state !== currentState.state) {
|
||||
if (state.state === 'playing') {
|
||||
this.play(true);
|
||||
} else if (state.state === 'paused') {
|
||||
this.pause(true);
|
||||
} else if (state.state === 'stopped') {
|
||||
this.stop(true);
|
||||
}
|
||||
}
|
||||
this.seekTo(state.seekOffset, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback seek position
|
||||
*/
|
||||
getCurrentSeekPosition(): number {
|
||||
if (this.playbackState$.getValue().state === 'playing') {
|
||||
// For playing state, use the actual current time from audio element
|
||||
return this.audioElement.currentTime;
|
||||
}
|
||||
// For other states, return the stored offset
|
||||
return this.playbackState$.getValue().seekOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the playback state data
|
||||
*/
|
||||
getPlaybackStateData() {
|
||||
return this.playbackState$.getValue();
|
||||
}
|
||||
|
||||
private async calcuateStatsFromBuffer(buffer: Blob) {
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = await audioContext.decodeAudioData(
|
||||
await buffer.arrayBuffer()
|
||||
);
|
||||
const waveform = await this.calculateWaveform(audioBuffer);
|
||||
return { waveform, duration: audioBuffer.duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the waveform of the audio buffer for visualization
|
||||
*/
|
||||
private async calculateWaveform(audioBuffer: AudioBuffer) {
|
||||
// Get the first channel's data
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samples = 1000; // Number of points in the waveform
|
||||
const blockSize = Math.floor(channelData.length / samples);
|
||||
const waveform = [];
|
||||
|
||||
// First pass: calculate raw averages
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const start = i * blockSize;
|
||||
const end = start + blockSize;
|
||||
let sum = 0;
|
||||
|
||||
for (let j = start; j < end; j++) {
|
||||
sum += Math.abs(channelData[j]);
|
||||
}
|
||||
|
||||
const average = sum / blockSize;
|
||||
waveform.push(average);
|
||||
}
|
||||
|
||||
// Second pass: normalize to make max value 1
|
||||
const maxValue = Math.max(...waveform);
|
||||
if (maxValue > 0) {
|
||||
for (let i = 0; i < waveform.length; i++) {
|
||||
waveform[i] = waveform[i] / maxValue;
|
||||
}
|
||||
}
|
||||
|
||||
return waveform;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { DesktopApiService } from '../desktop-api';
|
||||
import { GlobalState } from '../storage';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { AudioAttachmentBlock } from './entities/audio-attachment-block';
|
||||
import { AudioMedia } from './entities/audio-media';
|
||||
import {
|
||||
ElectronGlobalMediaStateProvider,
|
||||
GlobalMediaStateProvider,
|
||||
WebGlobalMediaStateProvider,
|
||||
} from './providers/global-audio-state';
|
||||
import { AudioAttachmentService } from './services/audio-attachment';
|
||||
import { AudioMediaManagerService } from './services/audio-media-manager';
|
||||
|
||||
export function configureMediaModule(framework: Framework) {
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
|
||||
GlobalState,
|
||||
])
|
||||
.scope(WorkspaceScope)
|
||||
.entity(AudioMedia, [WorkspaceService])
|
||||
.entity(AudioAttachmentBlock, [AudioMediaManagerService])
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
DesktopApiService,
|
||||
])
|
||||
.service(AudioAttachmentService);
|
||||
} else {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider)
|
||||
.scope(WorkspaceScope)
|
||||
.entity(AudioMedia, [WorkspaceService])
|
||||
.entity(AudioAttachmentBlock, [AudioMediaManagerService])
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
])
|
||||
.service(AudioAttachmentService);
|
||||
}
|
||||
}
|
||||
|
||||
export { AudioMedia, AudioMediaManagerService };
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createIdentifier,
|
||||
LiveData,
|
||||
type MediaStats,
|
||||
type PlaybackState,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import type { GlobalState } from '../../storage';
|
||||
|
||||
const GLOBAL_MEDIA_PLAYBACK_STATE_KEY = 'media:playback-state';
|
||||
const GLOBAL_MEDIA_STATS_KEY = 'media:stats';
|
||||
|
||||
export const GlobalMediaStateProvider =
|
||||
createIdentifier<BaseGlobalMediaStateProvider>('GlobalMediaStateProvider');
|
||||
|
||||
/**
|
||||
* Base class for media state providers
|
||||
*/
|
||||
export abstract class BaseGlobalMediaStateProvider {
|
||||
abstract readonly playbackState$: LiveData<PlaybackState | null | undefined>;
|
||||
abstract readonly stats$: LiveData<MediaStats | null | undefined>;
|
||||
|
||||
/**
|
||||
* Update the playback state
|
||||
* @param state Full state object or partial state to update
|
||||
*/
|
||||
abstract updatePlaybackState(state: Partial<PlaybackState> | null): void;
|
||||
|
||||
/**
|
||||
* Update the media stats
|
||||
* @param stats Full stats object or partial stats to update
|
||||
*/
|
||||
abstract updateStats(stats: Partial<MediaStats> | null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for global media state in Electron environment
|
||||
* This ensures only one media is playing at a time across all tabs
|
||||
*/
|
||||
export class ElectronGlobalMediaStateProvider extends BaseGlobalMediaStateProvider {
|
||||
constructor(private readonly globalState: GlobalState) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly playbackState$ = LiveData.from<PlaybackState | null | undefined>(
|
||||
this.globalState.watch(GLOBAL_MEDIA_PLAYBACK_STATE_KEY),
|
||||
this.globalState.get(GLOBAL_MEDIA_PLAYBACK_STATE_KEY)
|
||||
);
|
||||
readonly stats$ = LiveData.from<MediaStats | null | undefined>(
|
||||
this.globalState.watch(GLOBAL_MEDIA_STATS_KEY),
|
||||
this.globalState.get(GLOBAL_MEDIA_STATS_KEY)
|
||||
);
|
||||
|
||||
override updatePlaybackState(state: Partial<PlaybackState> | null): void {
|
||||
if (state === null) {
|
||||
this.globalState.set(GLOBAL_MEDIA_PLAYBACK_STATE_KEY, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.playbackState$.value;
|
||||
const newState = currentState
|
||||
? { ...currentState, ...state }
|
||||
: (state as PlaybackState);
|
||||
|
||||
this.globalState.set(GLOBAL_MEDIA_PLAYBACK_STATE_KEY, newState);
|
||||
}
|
||||
|
||||
override updateStats(stats: Partial<MediaStats> | null): void {
|
||||
if (stats === null) {
|
||||
this.globalState.set(GLOBAL_MEDIA_STATS_KEY, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStats = this.stats$.value;
|
||||
const newStats = currentStats
|
||||
? { ...currentStats, ...stats }
|
||||
: (stats as MediaStats);
|
||||
|
||||
this.globalState.set(GLOBAL_MEDIA_STATS_KEY, newStats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for global media state in Web environment
|
||||
* This is a simplified version that only works within the current tab
|
||||
*/
|
||||
export class WebGlobalMediaStateProvider extends BaseGlobalMediaStateProvider {
|
||||
readonly playbackState$ = new LiveData<PlaybackState | null | undefined>(
|
||||
null
|
||||
);
|
||||
readonly stats$ = new LiveData<MediaStats | null | undefined>(null);
|
||||
|
||||
/**
|
||||
* Update the playback state
|
||||
*/
|
||||
override updatePlaybackState(state: Partial<PlaybackState> | null): void {
|
||||
if (state === null) {
|
||||
this.playbackState$.setValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.playbackState$.value;
|
||||
const newState = currentState
|
||||
? { ...currentState, ...state }
|
||||
: (state as PlaybackState);
|
||||
|
||||
this.playbackState$.setValue(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the media stats
|
||||
*/
|
||||
override updateStats(stats: Partial<MediaStats> | null): void {
|
||||
if (stats === null) {
|
||||
this.stats$.setValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStats = this.stats$.value;
|
||||
const newStats = currentStats
|
||||
? { ...currentStats, ...stats }
|
||||
: (stats as MediaStats);
|
||||
|
||||
this.stats$.setValue(newStats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import {
|
||||
attachmentBlockAudioMediaKey,
|
||||
type AudioMediaKey,
|
||||
ObjectPool,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { AudioAttachmentBlock } from '../entities/audio-attachment-block';
|
||||
|
||||
export class AudioAttachmentService extends Service {
|
||||
private readonly pool = new ObjectPool<AudioMediaKey, AudioAttachmentBlock>({
|
||||
onDelete: block => {
|
||||
block.dispose();
|
||||
},
|
||||
onDangling: block => {
|
||||
return !block.rendering$.value;
|
||||
},
|
||||
});
|
||||
|
||||
get(model: AttachmentBlockModel | AudioMediaKey) {
|
||||
if (typeof model === 'string') {
|
||||
return this.pool.get(model);
|
||||
}
|
||||
if (!model.props.sourceId) {
|
||||
throw new Error('Source ID is required');
|
||||
}
|
||||
const key = attachmentBlockAudioMediaKey({
|
||||
blobId: model.props.sourceId,
|
||||
blockId: model.id,
|
||||
docId: model.doc.id,
|
||||
workspaceId: model.doc.rootDoc.guid,
|
||||
});
|
||||
let exists = this.pool.get(key);
|
||||
if (!exists) {
|
||||
const entity = this.framework.createEntity(AudioAttachmentBlock, model);
|
||||
exists = this.pool.put(key, entity);
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { generateUrl } from '@affine/core/components/hooks/affine/use-share-url';
|
||||
import { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import {
|
||||
attachmentBlockAudioMediaKey,
|
||||
type AudioMediaDescriptor,
|
||||
type AudioMediaKey,
|
||||
type MediaStats,
|
||||
ObjectPool,
|
||||
parseAudioMediaKey,
|
||||
type PlaybackState,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import type { DesktopApiService } from '../../desktop-api';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
import { AudioMedia } from '../entities/audio-media';
|
||||
import type { BaseGlobalMediaStateProvider } from '../providers/global-audio-state';
|
||||
|
||||
// Media service is to control how media should be played for attachment block
|
||||
// At a time, only one media can be played.
|
||||
export class AudioMediaManagerService extends Service {
|
||||
private readonly mediaPool = new ObjectPool<AudioMediaKey, AudioMedia>({
|
||||
onDelete: media => {
|
||||
media.dispose();
|
||||
const disposables = this.mediaDisposables.get(media);
|
||||
if (disposables) {
|
||||
disposables.forEach(dispose => dispose());
|
||||
this.mediaDisposables.delete(media);
|
||||
}
|
||||
},
|
||||
onDangling: media => {
|
||||
return media.playbackState$.getValue().state !== 'playing';
|
||||
},
|
||||
});
|
||||
|
||||
private readonly mediaDisposables = new WeakMap<AudioMedia, (() => void)[]>();
|
||||
|
||||
constructor(
|
||||
private readonly globalMediaState: BaseGlobalMediaStateProvider,
|
||||
private readonly workbench: WorkbenchService,
|
||||
private readonly desktopApi?: DesktopApiService
|
||||
) {
|
||||
super();
|
||||
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
this.desktopApi = undefined;
|
||||
}
|
||||
|
||||
this.disposables.push(() => {
|
||||
this.mediaPool.clear();
|
||||
});
|
||||
|
||||
// Subscribe to global playback state changes to manage playback across tabs
|
||||
this.disposables.push(
|
||||
this.observeGlobalPlaybackState(state => {
|
||||
if (!state) {
|
||||
// If global state is cleared, stop all media
|
||||
this.stopAllMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStats = this.getGlobalMediaStats();
|
||||
|
||||
if (!activeStats) return;
|
||||
|
||||
if (
|
||||
BUILD_CONFIG.isElectron &&
|
||||
activeStats.tabId !== this.desktopApi?.appInfo.viewId
|
||||
) {
|
||||
// other tab is playing, pause the current media
|
||||
if (state.state === 'playing') {
|
||||
this.pauseAllMedia();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaRef = this.ensureMediaEntity(activeStats);
|
||||
const media = mediaRef.media;
|
||||
|
||||
this.ensureExclusivePlayback();
|
||||
media.syncState(state);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
mediaRef.release();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.stopAllMedia();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper method to observe global playback state changes
|
||||
private observeGlobalPlaybackState(
|
||||
callback: (state: PlaybackState | undefined) => (() => void) | undefined
|
||||
): () => void {
|
||||
const unsubscribe = this.globalMediaState.playbackState$
|
||||
.pipe(distinctUntilChanged((a, b) => a?.updateTime === b?.updateTime))
|
||||
.subscribe(state => {
|
||||
if (state) {
|
||||
return callback(state);
|
||||
}
|
||||
return;
|
||||
});
|
||||
return () => {
|
||||
unsubscribe.unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
get playbackState$() {
|
||||
return this.globalMediaState.playbackState$;
|
||||
}
|
||||
|
||||
get playbackStats$() {
|
||||
return this.globalMediaState.stats$;
|
||||
}
|
||||
|
||||
ensureMediaEntity(input: AttachmentBlockModel | MediaStats) {
|
||||
const descriptor = this.normalizeEntityDescriptor(input);
|
||||
|
||||
let rc = this.mediaPool.get(descriptor.key);
|
||||
if (!rc) {
|
||||
rc = this.mediaPool.put(
|
||||
descriptor.key,
|
||||
this.framework.createEntity(AudioMedia, {
|
||||
blobId: descriptor.blobId,
|
||||
metadata: new MediaMetadata({
|
||||
title: descriptor.name,
|
||||
artist: 'AFFiNE',
|
||||
// todo: add artwork, like the app icon?
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const audioMedia = rc.obj;
|
||||
|
||||
// Set up playback state synchronization (broadcast to global state)
|
||||
const playbackStateSubscription = audioMedia.playbackState$
|
||||
.pipe(distinctUntilChanged((a, b) => a.updateTime === b.updateTime))
|
||||
.subscribe(state => {
|
||||
if (state.state === 'playing') {
|
||||
this.globalMediaState.updateStats({
|
||||
...audioMedia.getStats(),
|
||||
tabId: descriptor.tabId,
|
||||
key: descriptor.key,
|
||||
name: descriptor.name,
|
||||
size: descriptor.size,
|
||||
});
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
tabId: descriptor.tabId,
|
||||
key: descriptor.key,
|
||||
...audioMedia.getPlaybackStateData(),
|
||||
});
|
||||
} else if (
|
||||
(state.state === 'paused' || state.state === 'stopped') &&
|
||||
this.globalMediaState.stats$.value?.key === descriptor.key
|
||||
) {
|
||||
// If this is the active media and it's paused/stopped, update global state
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
tabId: descriptor.tabId,
|
||||
key: descriptor.key,
|
||||
...audioMedia.getPlaybackStateData(),
|
||||
});
|
||||
if (state.state === 'stopped') {
|
||||
this.globalMediaState.updateStats(null);
|
||||
this.globalMediaState.updatePlaybackState(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaDisposables.set(audioMedia, [
|
||||
() => playbackStateSubscription.unsubscribe(),
|
||||
() => {
|
||||
// if the audioMedia is the active media, remove it
|
||||
if (this.getActiveMediaKey() === descriptor.key) {
|
||||
this.globalMediaState.updatePlaybackState(null);
|
||||
this.globalMediaState.updateStats(null);
|
||||
}
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return { media: rc.obj, release: rc.release };
|
||||
}
|
||||
|
||||
play() {
|
||||
const stats = this.getGlobalMediaStats();
|
||||
const currentState = this.getGlobalPlaybackState();
|
||||
if (!stats || !currentState) {
|
||||
return;
|
||||
}
|
||||
const seekOffset =
|
||||
currentState.seekOffset + (Date.now() - currentState.updateTime) / 1000;
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
state: 'playing',
|
||||
// rewind to the beginning if the seek offset is greater than the duration
|
||||
seekOffset: seekOffset >= stats.duration ? 0 : seekOffset,
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
pause() {
|
||||
const state = this.getGlobalPlaybackState();
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
state: 'paused',
|
||||
seekOffset: (Date.now() - state.updateTime) / 1000 + state.seekOffset,
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
state: 'stopped',
|
||||
seekOffset: 0,
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
seekTo(time: number) {
|
||||
const stats = this.getGlobalMediaStats();
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
this.globalMediaState.updatePlaybackState({
|
||||
seekOffset: clamp(0, time, stats.duration),
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
focusAudioMedia(key: AudioMediaKey, tabId: string | null) {
|
||||
const mediaProps = parseAudioMediaKey(key);
|
||||
if (tabId === this.currentTabId) {
|
||||
this.workbench.workbench.openDoc({
|
||||
docId: mediaProps.docId,
|
||||
mode: 'page',
|
||||
blockIds: [mediaProps.blockId],
|
||||
});
|
||||
} else if (BUILD_CONFIG.isElectron && tabId) {
|
||||
const url = generateUrl({
|
||||
baseUrl: window.location.origin,
|
||||
workspaceId: mediaProps.workspaceId,
|
||||
pageId: mediaProps.docId,
|
||||
blockIds: [mediaProps.blockId],
|
||||
});
|
||||
|
||||
this.desktopApi?.showTab(tabId, url).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private getActiveMediaKey(): AudioMediaKey | null {
|
||||
const stats = this.getGlobalMediaStats();
|
||||
return stats?.key || null;
|
||||
}
|
||||
|
||||
private getGlobalPlaybackState(): PlaybackState | null {
|
||||
const provider = this.globalMediaState;
|
||||
return provider.playbackState$.value || null;
|
||||
}
|
||||
|
||||
private getGlobalMediaStats(): MediaStats | null {
|
||||
const provider = this.globalMediaState;
|
||||
return provider.stats$.value || null;
|
||||
}
|
||||
|
||||
// Ensure only one media is playing at a time
|
||||
private ensureExclusivePlayback() {
|
||||
const activeKey = this.getActiveMediaKey();
|
||||
if (activeKey) {
|
||||
this.pauseAllMedia(activeKey);
|
||||
}
|
||||
}
|
||||
|
||||
get currentTabId() {
|
||||
return this.desktopApi?.appInfo.viewId || 'web';
|
||||
}
|
||||
|
||||
private normalizeEntityDescriptor(
|
||||
input: AttachmentBlockModel | MediaStats
|
||||
): AudioMediaDescriptor {
|
||||
if (input instanceof AttachmentBlockModel) {
|
||||
if (!input.props.sourceId) {
|
||||
throw new Error('Invalid media');
|
||||
}
|
||||
return {
|
||||
key: attachmentBlockAudioMediaKey({
|
||||
blobId: input.props.sourceId,
|
||||
blockId: input.id,
|
||||
docId: input.doc.id,
|
||||
workspaceId: input.doc.rootDoc.guid,
|
||||
}),
|
||||
name: input.props.name,
|
||||
size: input.props.size,
|
||||
blobId: input.props.sourceId,
|
||||
// when input is AttachmentBlockModel, it is always in the current tab
|
||||
tabId: this.currentTabId,
|
||||
};
|
||||
} else {
|
||||
const { blobId } = parseAudioMediaKey(input.key);
|
||||
return {
|
||||
key: input.key,
|
||||
name: input.name,
|
||||
size: input.size,
|
||||
blobId,
|
||||
tabId: input.tabId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all playing media except the one with the given ID
|
||||
* IN THE CURRENT TAB
|
||||
*/
|
||||
private pauseAllMedia(exceptId?: AudioMediaKey) {
|
||||
// Iterate through all objects in the pool
|
||||
for (const [id, ref] of this.mediaPool.objects) {
|
||||
if (
|
||||
id !== exceptId &&
|
||||
ref.obj.playbackState$.getValue().state === 'playing'
|
||||
) {
|
||||
ref.obj.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stopAllMedia(exceptId?: AudioMediaKey) {
|
||||
// Iterate through all objects in the pool
|
||||
for (const [id, ref] of this.mediaPool.objects) {
|
||||
if (
|
||||
id !== exceptId &&
|
||||
ref.obj.playbackState$.getValue().state === 'playing'
|
||||
) {
|
||||
ref.obj.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// The media entity may not being created yet
|
||||
// so we need to change the state
|
||||
const globalState = this.getGlobalPlaybackState();
|
||||
if (
|
||||
globalState &&
|
||||
globalState.key !== exceptId &&
|
||||
globalState.tabId === this.currentTabId
|
||||
) {
|
||||
this.globalMediaState.updatePlaybackState(null);
|
||||
this.globalMediaState.updateStats(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
|
||||
export function getAttachmentType(model: AttachmentBlockModel) {
|
||||
// Check MIME type first
|
||||
if (model.props.type.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (model.props.type.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (model.props.type.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (model.props.type === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
// If MIME type doesn't match, check file extension
|
||||
const ext = model.props.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (
|
||||
[
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'svg',
|
||||
'avif',
|
||||
'tiff',
|
||||
'bmp',
|
||||
].includes(ext)
|
||||
) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (
|
||||
['mp4', 'webm', 'avi', 'mov', 'mkv', 'mpeg', 'ogv', '3gp'].includes(ext)
|
||||
) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
||||
const sourceId = model.props.sourceId;
|
||||
if (!sourceId) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
const blob = await model.doc.blobSync.get(sourceId);
|
||||
if (!blob) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
return arrayBuffer;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AudioAttachmentBlock } from '../entities/audio-attachment-block';
|
||||
import { AudioAttachmentService } from '../services/audio-attachment';
|
||||
|
||||
export const useAttachmentMediaBlock = (model: AttachmentBlockModel) => {
|
||||
const audioAttachmentService = useService(AudioAttachmentService);
|
||||
const [audioAttachmentBlock, setAttachmentMedia] = useState<
|
||||
AudioAttachmentBlock | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model.props.sourceId) {
|
||||
return;
|
||||
}
|
||||
const entity = audioAttachmentService.get(model);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
const audioAttachmentBlock = entity.obj;
|
||||
setAttachmentMedia(audioAttachmentBlock);
|
||||
audioAttachmentBlock.mount();
|
||||
return () => {
|
||||
audioAttachmentBlock.unmount();
|
||||
entity.release();
|
||||
};
|
||||
}, [audioAttachmentService, model]);
|
||||
return audioAttachmentBlock;
|
||||
};
|
||||
@@ -2,8 +2,9 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
|
||||
import { catchError, from, map, of, startWith, switchMap } from 'rxjs';
|
||||
|
||||
import { downloadBlobToBuffer } from '../../media/utils';
|
||||
import type { PDFMeta } from '../renderer';
|
||||
import { downloadBlobToBuffer, PDFRenderer } from '../renderer';
|
||||
import { PDFRenderer } from '../renderer';
|
||||
import { PDFPage } from './pdf-page';
|
||||
|
||||
export enum PDFStatus {
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { PDFRenderer } from './renderer';
|
||||
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
export { downloadBlobToBuffer } from './utils';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
|
||||
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
||||
const sourceId = model.props.sourceId;
|
||||
if (!sourceId) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
const blob = await model.doc.blobSync.get(sourceId);
|
||||
if (!blob) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
return await blob.arrayBuffer();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AttachmentViewer } from '@affine/core/blocksuite/attachment-viewer';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttachmentViewer } from '../../../../components/attachment-viewer';
|
||||
import { useEditor } from '../utils';
|
||||
|
||||
export type AttachmentPreviewModalProps = {
|
||||
|
||||
@@ -111,7 +111,6 @@ export const PeekViewManagerModal = () => {
|
||||
}, []);
|
||||
|
||||
const onAnimationEnd = useCallback(() => {
|
||||
console.log('onAnimationEnd');
|
||||
setAnimating(false);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -84,6 +84,12 @@ export class DesktopStateSynchronizer extends Service {
|
||||
}
|
||||
});
|
||||
|
||||
this.electronApi.events.ui.onTabGoToRequest(opts => {
|
||||
if (opts.tabId === appInfo?.viewId) {
|
||||
this.workbenchService.workbench.open(opts.to);
|
||||
}
|
||||
});
|
||||
|
||||
// sync workbench state with main process
|
||||
// also fill tab view meta with title & moduleName
|
||||
LiveData.computed(get => {
|
||||
|
||||
Reference in New Issue
Block a user