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:
pengx17
2025-03-20 12:46:14 +00:00
parent 8a5393ea50
commit fad49bb070
120 changed files with 5407 additions and 950 deletions
@@ -1,131 +0,0 @@
import { Button } from '@affine/component';
import { useI18n } from '@affine/i18n';
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import { ArrowDownBigIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
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 { download } from './utils';
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
// TODO: should move file icons to icons repo
const FileIcon = () => (
<svg
width="96"
height="96"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.80781 1.875L10.8332 1.875C10.9989 1.875 11.1579 1.94085 11.2751 2.05806L16.2751 7.05806C16.3923 7.17527 16.4582 7.33424 16.4582 7.5V14.8587C16.4582 15.3038 16.4582 15.6754 16.4334 15.9789C16.4075 16.2955 16.3516 16.5927 16.2084 16.8737C15.9887 17.3049 15.6381 17.6555 15.2069 17.8752C14.9258 18.0184 14.6286 18.0744 14.3121 18.1002C14.0085 18.125 13.637 18.125 13.1919 18.125H6.80779C6.36267 18.125 5.99114 18.125 5.68761 18.1002C5.37104 18.0744 5.07383 18.0184 4.79278 17.8752C4.36157 17.6555 4.01099 17.3049 3.79128 16.8737C3.64808 16.5927 3.59215 16.2955 3.56629 15.9789C3.54149 15.6754 3.5415 15.3038 3.5415 14.8587V5.1413C3.5415 4.69618 3.54149 4.32464 3.56629 4.02111C3.59215 3.70454 3.64808 3.40732 3.79128 3.12627C4.01099 2.69507 4.36157 2.34449 4.79278 2.12478C5.07383 1.98157 5.37104 1.92565 5.68761 1.89978C5.99114 1.87498 6.36268 1.87499 6.80781 1.875ZM5.7894 3.14563C5.55013 3.16518 5.43573 3.20008 5.36026 3.23854C5.16426 3.3384 5.00491 3.49776 4.90504 3.69376C4.86659 3.76923 4.83168 3.88363 4.81214 4.1229C4.79199 4.36946 4.7915 4.68964 4.7915 5.16667V14.8333C4.7915 15.3104 4.79199 15.6305 4.81214 15.8771C4.83168 16.1164 4.86659 16.2308 4.90504 16.3062C5.00491 16.5022 5.16426 16.6616 5.36026 16.7615C5.43573 16.7999 5.55013 16.8348 5.7894 16.8544C6.03597 16.8745 6.35615 16.875 6.83317 16.875H13.1665C13.6435 16.875 13.9637 16.8745 14.2103 16.8544C14.4495 16.8348 14.5639 16.7999 14.6394 16.7615C14.8354 16.6616 14.9948 16.5022 15.0946 16.3062C15.1331 16.2308 15.168 16.1164 15.1875 15.8771C15.2077 15.6305 15.2082 15.3104 15.2082 14.8333V8.125H11.6665C10.8611 8.125 10.2082 7.47208 10.2082 6.66667V3.125H6.83317C6.35615 3.125 6.03597 3.12549 5.7894 3.14563ZM11.4582 4.00888L14.3243 6.875H11.6665C11.5514 6.875 11.4582 6.78173 11.4582 6.66667V4.00888Z"
fill="#77757D"
/>
</svg>
);
const PDFFileIcon = () => (
<svg
width="96"
height="96"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.75 4C7.75 2.20508 9.20508 0.75 11 0.75H27C27.1212 0.75 27.2375 0.798159 27.3232 0.883885L38.1161 11.6768C38.2018 11.7625 38.25 11.8788 38.25 12V36C38.25 37.7949 36.7949 39.25 35 39.25H11C9.20507 39.25 7.75 37.7949 7.75 36V4Z"
fill="white"
stroke="#D0D5DD"
strokeWidth="1.5"
/>
<path
d="M27 0.5V8C27 10.2091 28.7909 12 31 12H38.5"
stroke="#D0D5DD"
strokeWidth="1.5"
/>
<rect x="1" y="18" width="26" height="16" rx="2" fill="#D92D20" />
<path
d="M4.8323 30V22.7273H7.70162C8.25323 22.7273 8.72316 22.8326 9.11142 23.0433C9.49967 23.2517 9.7956 23.5417 9.9992 23.9134C10.2052 24.2827 10.3082 24.7088 10.3082 25.1918C10.3082 25.6747 10.204 26.1009 9.99565 26.4702C9.78732 26.8395 9.48547 27.1271 9.09011 27.3331C8.69712 27.5391 8.22127 27.642 7.66255 27.642H5.83372V26.4098H7.41397C7.7099 26.4098 7.95375 26.3589 8.14551 26.2571C8.33964 26.1529 8.48405 26.0097 8.57875 25.8274C8.67581 25.6428 8.72434 25.4309 8.72434 25.1918C8.72434 24.9503 8.67581 24.7396 8.57875 24.5597C8.48405 24.3774 8.33964 24.2365 8.14551 24.1371C7.95138 24.0353 7.70517 23.9844 7.40687 23.9844H6.36994V30H4.8323ZM13.885 30H11.3069V22.7273H13.9063C14.6379 22.7273 15.2676 22.8729 15.7955 23.1641C16.3235 23.4529 16.7295 23.8684 17.0136 24.4105C17.3 24.9527 17.4433 25.6013 17.4433 26.3565C17.4433 27.1141 17.3 27.7652 17.0136 28.3097C16.7295 28.8542 16.3211 29.272 15.7884 29.5632C15.2581 29.8544 14.6237 30 13.885 30ZM12.8445 28.6825H13.8211C14.2757 28.6825 14.658 28.602 14.9681 28.4411C15.2806 28.2777 15.515 28.0256 15.6713 27.6847C15.8299 27.3414 15.9092 26.8987 15.9092 26.3565C15.9092 25.8191 15.8299 25.38 15.6713 25.0391C15.515 24.6982 15.2818 24.4472 14.9717 24.2862C14.6615 24.1252 14.2792 24.0447 13.8247 24.0447H12.8445V28.6825ZM18.5823 30V22.7273H23.3976V23.995H20.1199V25.728H23.078V26.9957H20.1199V30H18.5823Z"
fill="white"
/>
</svg>
);
const FILE_ICONS: Record<string, () => ReactElement> = {
'application/pdf': PDFFileIcon,
};
interface ErrorBaseProps {
title: string;
subtitle?: string;
icon?: ReactElement;
buttons?: ReactElement[];
}
export const ErrorBase = ({
title,
subtitle,
icon = <FileIcon />,
buttons = [],
}: ErrorBaseProps) => {
return (
<div className={clsx([styles.viewer, styles.error])}>
{icon}
<h3 className={styles.errorTitle}>{title}</h3>
<p className={styles.errorMessage}>{subtitle}</p>
<div className={styles.errorBtns}>{buttons}</div>
</div>
);
};
interface ErrorProps {
model: AttachmentBlockModel;
ext: string;
}
export const Error = ({ model, ext }: ErrorProps) => {
const t = useI18n();
const Icon = FILE_ICONS[model.props.type] ?? FileIcon;
const title = t['com.affine.attachment.preview.error.title']();
const subtitle = `.${ext} ${t['com.affine.attachment.preview.error.subtitle']()}`;
return (
<ErrorBase
icon={<Icon />}
title={title}
subtitle={subtitle}
buttons={[
<Button
key="download"
variant="primary"
prefix={<ArrowDownBigIcon />}
onClick={() => {
download(model).catch(console.error);
}}
>
Download
</Button>,
]}
/>
);
};
const ErrorBoundaryInner = (props: FallbackProps): ReactElement => {
const t = useI18n();
const title = t['com.affine.attachment.preview.error.title']();
const subtitle = `${props.error}`;
return <ErrorBase title={title} subtitle={subtitle} />;
};
export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => {
return (
<ErrorBoundary FallbackComponent={ErrorBoundaryInner}>
<Suspense>{props.children}</Suspense>
</ErrorBoundary>
);
};
@@ -1,46 +0,0 @@
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 { buildAttachmentProps } from './utils';
// In Peek view
export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
const props = buildAttachmentProps(model);
return (
<div className={styles.viewerContainer}>
<Titlebar {...props} />
<AttachmentViewerInner {...props} />
</div>
);
};
// In View container
export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
const props = buildAttachmentProps(model);
return (
<>
<ViewHeader>
<Titlebar {...props} />
</ViewHeader>
<ViewBody>
<AttachmentViewerInner {...props} />
</ViewBody>
</>
);
};
const AttachmentViewerInner = (props: PDFViewerProps) => {
return props.model.props.type.endsWith('pdf') ? (
<AttachmentPreviewErrorBoundary>
<PDFViewer {...props} />
</AttachmentPreviewErrorBoundary>
) : (
<Error {...props} />
);
};
@@ -1,271 +0,0 @@
import { IconButton, observeIntersection } from '@affine/component';
import {
type PDF,
type PDFPage,
PDFService,
PDFStatus,
} from '@affine/core/modules/pdf';
import type { PDFMeta } from '@affine/core/modules/pdf/renderer';
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views';
import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view';
import { stopPropagation } from '@affine/core/utils';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
AttachmentIcon,
CenterPeekIcon,
} from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import {
type MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './styles.css';
import * as embeddedStyles from './styles.embedded.css';
import type { PDFViewerProps } from './types';
function defaultMeta() {
return {
pageCount: 0,
pageSizes: [],
maxSize: { width: 0, height: 0 },
};
}
export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
const scale = window.devicePixelRatio;
const peekView = useService(PeekViewService).peekView;
const pdfService = useService(PDFService);
const [pdfEntity, setPdfEntity] = useState<{
pdf: PDF;
release: () => void;
} | null>(null);
const [pageEntity, setPageEntity] = useState<{
page: PDFPage;
release: () => void;
} | null>(null);
const [pageSize, setPageSize] = useState<PageSize | null>(null);
const meta = useLiveData(
useMemo(() => {
return pdfEntity
? pdfEntity.pdf.state$.map(s => {
return s.status === PDFStatus.Opened ? s.meta : defaultMeta();
})
: new LiveData<PDFMeta>(defaultMeta());
}, [pdfEntity])
);
const img = useLiveData(
useMemo(() => (pageEntity ? pageEntity.page.bitmap$ : null), [pageEntity])
);
const [name, setName] = useState(model.props.name);
const [cursor, setCursor] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [visibility, setVisibility] = useState(false);
const viewerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const peek = useCallback(() => {
const target = model.doc.getBlock(model.id);
if (!target) return;
peekView.open({ element: target }).catch(console.error);
}, [peekView, model]);
const navigator = useMemo(() => {
const p = cursor - 1;
const n = cursor + 1;
return {
prev: {
disabled: p < 0,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(p);
},
},
next: {
disabled: n >= meta.pageCount,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(n);
},
},
peek: {
onClick: (e: MouseEvent) => {
e.stopPropagation();
peek();
},
},
};
}, [cursor, meta, peek]);
useEffect(() => model.props.name$.subscribe(val => setName(val)), [model]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!img) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
setIsLoading(false);
canvas.width = img.width;
canvas.height = img.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}, [img]);
useEffect(() => {
if (!visibility) return;
if (!pageEntity) return;
if (!pageSize) return;
const { width, height } = pageSize;
pageEntity.page.render({ width, height, scale });
return () => {
pageEntity.page.render.unsubscribe();
};
}, [visibility, pageEntity, pageSize, scale]);
useEffect(() => {
if (!visibility) return;
if (!pdfEntity) return;
const size = meta.pageSizes[cursor];
if (!size) return;
const { width, height } = size;
const pageEntity = pdfEntity.pdf.page(
cursor,
`${width}:${height}:${scale}`
);
setPageEntity(pageEntity);
setPageSize(size);
return () => {
pageEntity.release();
setPageSize(null);
setPageEntity(null);
};
}, [visibility, pdfEntity, cursor, meta, scale]);
useEffect(() => {
if (!visibility) return;
const pdfEntity = pdfService.get(model);
setPdfEntity(pdfEntity);
return () => {
pdfEntity.release();
setPdfEntity(null);
};
}, [model, pdfService, visibility]);
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
return observeIntersection(
viewer,
debounce(
entry => {
setVisibility(entry.isIntersecting);
},
377,
{
trailing: true,
}
)
);
}, []);
return (
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
<main className={embeddedStyles.pdfViewer}>
<div
className={styles.pdfPage}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '253px',
}}
>
<PDFPageCanvas ref={canvasRef} />
<LoadingSvg
style={{
position: 'absolute',
visibility: isLoading ? 'visible' : 'hidden',
}}
/>
</div>
<div className={embeddedStyles.pdfControls}>
<IconButton
size={16}
icon={<ArrowUpSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
aria-label="Prev"
{...navigator.prev}
/>
<IconButton
size={16}
icon={<ArrowDownSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
aria-label="Next"
{...navigator.next}
/>
<IconButton
size={16}
icon={<CenterPeekIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.peek}
/>
</div>
</main>
<footer className={embeddedStyles.pdfFooter}>
<div
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
>
<AttachmentIcon />
<span className={clsx([embeddedStyles.pdfTitle, 'pdf-name'])}>
{name}
</span>
</div>
<div
className={clsx([
embeddedStyles.pdfFooterItem,
embeddedStyles.pdfPageCount,
])}
>
<span className="page-cursor">
{meta.pageCount > 0 ? cursor + 1 : '-'}
</span>
/
<span className="page-count">
{meta.pageCount > 0 ? meta.pageCount : '-'}
</span>
</div>
</footer>
</div>
);
}
@@ -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,236 +0,0 @@
import { IconButton, observeResize } from '@affine/component';
import type {
PDF,
PDFRendererState,
PDFStatus,
} from '@affine/core/modules/pdf';
import {
Item,
List,
ListPadding,
ListWithSmallGap,
PDFPageRenderer,
type PDFVirtuosoContext,
type PDFVirtuosoProps,
Scroller,
ScrollSeekPlaceholder,
} from '@affine/core/modules/pdf/views';
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type ScrollSeekConfiguration,
Virtuoso,
type VirtuosoHandle,
} from 'react-virtuoso';
import * as styles from './styles.css';
import { calculatePageNum, fitToPage } from './utils';
const THUMBNAIL_WIDTH = 94;
export interface PDFViewerInnerProps {
pdf: PDF;
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
}
export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
const [cursor, setCursor] = useState(0);
const [collapsed, setCollapsed] = useState(true);
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
const viewerRef = useRef<HTMLDivElement>(null);
const pagesScrollerRef = useRef<HTMLElement | null>(null);
const pagesScrollerHandleRef = useRef<VirtuosoHandle>(null);
const thumbnailsScrollerHandleRef = useRef<VirtuosoHandle>(null);
const updateScrollerRef = useCallback(
(scroller: HTMLElement | Window | null) => {
pagesScrollerRef.current = scroller as HTMLElement;
},
[]
);
const onScroll = useCallback(() => {
const el = pagesScrollerRef.current;
if (!el) return;
const { pageCount } = state.meta;
if (!pageCount) return;
const cursor = calculatePageNum(el, pageCount);
setCursor(cursor);
}, [pagesScrollerRef, state]);
const onPageSelect = useCallback(
(index: number) => {
const scroller = pagesScrollerHandleRef.current;
if (!scroller) return;
scroller.scrollToIndex({
index,
align: 'center',
behavior: 'smooth',
});
},
[pagesScrollerHandleRef]
);
const pageContent = useCallback(
(
index: number,
_: unknown,
{
viewportInfo,
meta,
onPageSelect,
pageClassName,
resize,
isThumbnail,
}: PDFVirtuosoContext
) => {
return (
<PDFPageRenderer
key={`${pageClassName}-${index}`}
pdf={pdf}
pageNum={index}
className={pageClassName}
viewportInfo={viewportInfo}
actualSize={meta.pageSizes[index]}
maxSize={meta.maxSize}
onSelect={onPageSelect}
resize={resize}
isThumbnail={isThumbnail}
/>
);
},
[pdf]
);
const thumbnailsConfig = useMemo(() => {
const { height: vh } = viewportInfo;
const { pageCount, pageSizes, maxSize } = state.meta;
const t = Math.min(maxSize.width / maxSize.height, 1);
const pw = THUMBNAIL_WIDTH / t;
const newMaxSize = {
width: pw,
height: pw * (maxSize.height / maxSize.width),
};
const newPageSizes = pageSizes.map(({ width, height }) => {
const w = newMaxSize.width * (width / maxSize.width);
return {
width: w,
height: w * (height / width),
};
});
const height = Math.min(
vh - 60 - 24 - 24 - 2 - 8,
newPageSizes.reduce((h, { height }) => h + height * t, 0) +
(pageCount - 1) * 12
);
return {
context: {
onPageSelect,
viewportInfo: {
width: pw,
height,
},
meta: {
pageCount,
maxSize: newMaxSize,
pageSizes: newPageSizes,
},
resize: fitToPage,
isThumbnail: true,
pageClassName: styles.pdfThumbnail,
},
style: { height },
};
}, [state, viewportInfo, onPageSelect]);
// 1. works fine if they are the same size
// 2. uses the `observeIntersection` when targeting different sizes
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
return {
enter: velocity => Math.abs(velocity) > 1024,
exit: velocity => Math.abs(velocity) < 10,
};
}, []);
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
return observeResize(viewer, ({ contentRect: { width, height } }) =>
setViewportInfo({ width, height })
);
}, []);
return (
<div
ref={viewerRef}
data-testid="pdf-viewer"
className={clsx([styles.viewer, { gridding: true, scrollable: true }])}
>
<Virtuoso<PDFVirtuosoProps>
key={pdf.id}
ref={pagesScrollerHandleRef}
scrollerRef={updateScrollerRef}
onScroll={onScroll}
className={styles.virtuoso}
totalCount={state.meta.pageCount}
itemContent={pageContent}
components={{
Item,
List,
Scroller,
Header: ListPadding,
Footer: ListPadding,
ScrollSeekPlaceholder,
}}
context={{
viewportInfo: {
width: viewportInfo.width - 40,
height: viewportInfo.height - 40,
},
meta: state.meta,
resize: fitToPage,
pageClassName: styles.pdfPage,
}}
scrollSeekConfiguration={scrollSeekConfig}
/>
<div className={clsx(['thumbnails', styles.pdfThumbnails])}>
<div className={clsx([styles.pdfThumbnailsList, { collapsed }])}>
<Virtuoso<PDFVirtuosoProps>
key={`${pdf.id}-thumbnail`}
ref={thumbnailsScrollerHandleRef}
className={styles.virtuoso}
totalCount={state.meta.pageCount}
itemContent={pageContent}
components={{
Item,
List: ListWithSmallGap,
Scroller,
ScrollSeekPlaceholder,
}}
style={thumbnailsConfig.style}
context={thumbnailsConfig.context}
scrollSeekConfiguration={scrollSeekConfig}
/>
</div>
<div className={clsx(['indicator', styles.pdfIndicator])}>
<div>
<span className="page-cursor">
{state.meta.pageCount > 0 ? cursor + 1 : 0}
</span>
/<span className="page-count">{state.meta.pageCount}</span>
</div>
<IconButton
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
onClick={() => setCollapsed(!collapsed)}
/>
</div>
</div>
</div>
);
};
@@ -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,186 +0,0 @@
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 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',
justifyContent: 'space-between',
padding: '0 12px',
});
export const pdfPage = style({
maxWidth: 'calc(100% - 40px)',
background: cssVarV2('layer/white'),
boxSizing: 'border-box',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVarV2('layer/insideBorder/border'),
boxShadow:
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
overflow: 'hidden',
maxHeight: 'max-content',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const pdfThumbnails = style({
display: 'flex',
flexDirection: 'column',
position: 'absolute',
boxSizing: 'border-box',
width: '120px',
padding: '12px 0',
right: '30px',
bottom: '30px',
maxHeight: 'calc(100% - 60px)',
borderRadius: '8px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVarV2('layer/insideBorder/border'),
backgroundColor: cssVarV2('layer/background/primary'),
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
});
export const pdfThumbnailsList = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
overflow: 'hidden',
selectors: {
'&.collapsed': {
display: 'none',
},
'&:not(.collapsed)': {
marginBottom: '8px',
},
},
});
export const pdfThumbnail = style({
display: 'flex',
overflow: 'hidden',
// width: '100%',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVarV2('layer/insideBorder/border'),
selectors: {
'&.selected': {
borderColor: '#29A3FA',
},
},
});
@@ -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,105 +0,0 @@
import { IconButton, Menu, MenuItem } from '@affine/component';
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import {
//EditIcon,
LocalDataIcon,
MoreHorizontalIcon,
ZoomDownIcon,
ZoomUpIcon,
} from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useState } from 'react';
import * as styles from './styles.css';
import { download } from './utils';
const items = [
/*
{
name: 'Rename',
icon: <EditIcon />,
action(_model: AttachmentBlockModel) {},
},
*/
{
name: 'Download',
icon: <LocalDataIcon />,
action: download,
},
];
export const MenuItems = ({ model }: { model: AttachmentBlockModel }) =>
items.map(({ name, icon, action }) => (
<MenuItem
key={name}
onClick={() => {
action(model).catch(console.error);
}}
prefixIcon={icon}
>
{name}
</MenuItem>
));
export interface TitlebarProps {
model: AttachmentBlockModel;
name: string;
ext: string;
size: string;
zoom?: number;
}
export const Titlebar = ({
model,
name,
ext,
size,
zoom = 100,
}: TitlebarProps) => {
const [openMenu, setOpenMenu] = useState(false);
return (
<div className={styles.titlebar}>
<div className={styles.titlebarChild}>
<div className={styles.titlebarName}>
<div>{name}</div>
<span>.{ext}</span>
</div>
<div>{size}</div>
<IconButton
icon={<LocalDataIcon />}
onClick={() => {
download(model).catch(console.error);
}}
></IconButton>
<Menu
items={<MenuItems model={model} />}
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'center',
avoidCollisions: false,
}}
>
<IconButton icon={<MoreHorizontalIcon />}></IconButton>
</Menu>
</div>
<div
className={clsx([
styles.titlebarChild,
'zoom',
{
show: false,
},
])}
>
<IconButton icon={<ZoomDownIcon />}></IconButton>
<div>{zoom}%</div>
<IconButton icon={<ZoomUpIcon />}></IconButton>
</div>
</div>
);
};
@@ -1,12 +0,0 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
export type AttachmentViewerProps = {
model: AttachmentBlockModel;
};
export type PDFViewerProps = {
model: AttachmentBlockModel;
name: string;
ext: string;
size: string;
};
@@ -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>
);
};