mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 05:05:52 +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:
@@ -29,6 +29,7 @@ import {
|
|||||||
RootBlockSchemaExtension,
|
RootBlockSchemaExtension,
|
||||||
SurfaceRefBlockSchemaExtension,
|
SurfaceRefBlockSchemaExtension,
|
||||||
TableBlockSchemaExtension,
|
TableBlockSchemaExtension,
|
||||||
|
TranscriptionBlockSchemaExtension,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
HighlightSelectionExtension,
|
HighlightSelectionExtension,
|
||||||
@@ -84,6 +85,7 @@ export const StoreExtensions: ExtensionType[] = [
|
|||||||
LatexBlockSchemaExtension,
|
LatexBlockSchemaExtension,
|
||||||
TableBlockSchemaExtension,
|
TableBlockSchemaExtension,
|
||||||
CalloutBlockSchemaExtension,
|
CalloutBlockSchemaExtension,
|
||||||
|
TranscriptionBlockSchemaExtension,
|
||||||
|
|
||||||
BlockSelectionExtension,
|
BlockSelectionExtension,
|
||||||
TextSelectionExtension,
|
TextSelectionExtension,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
RootBlockSchema,
|
RootBlockSchema,
|
||||||
SurfaceRefBlockSchema,
|
SurfaceRefBlockSchema,
|
||||||
TableBlockSchema,
|
TableBlockSchema,
|
||||||
|
TranscriptionBlockSchema,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import type { BlockSchema } from '@blocksuite/store';
|
import type { BlockSchema } from '@blocksuite/store';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -56,4 +57,5 @@ export const AffineSchemas: z.infer<typeof BlockSchema>[] = [
|
|||||||
LatexBlockSchema,
|
LatexBlockSchema,
|
||||||
TableBlockSchema,
|
TableBlockSchema,
|
||||||
CalloutBlockSchema,
|
CalloutBlockSchema,
|
||||||
|
TranscriptionBlockSchema,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
|||||||
import { BlockSelection } from '@blocksuite/block-std';
|
import { BlockSelection } from '@blocksuite/block-std';
|
||||||
import { Slice } from '@blocksuite/store';
|
import { Slice } from '@blocksuite/store';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
import { property, state } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
@@ -34,10 +34,6 @@ import { checkAttachmentBlob, downloadAttachmentBlob } from './utils';
|
|||||||
export class AttachmentBlockComponent extends CaptionedBlockComponent<AttachmentBlockModel> {
|
export class AttachmentBlockComponent extends CaptionedBlockComponent<AttachmentBlockModel> {
|
||||||
static override styles = styles;
|
static override styles = styles;
|
||||||
|
|
||||||
protected _isDragging = false;
|
|
||||||
|
|
||||||
protected _isResizing = false;
|
|
||||||
|
|
||||||
blockDraggable = true;
|
blockDraggable = true;
|
||||||
|
|
||||||
protected containerStyleMap = styleMap({
|
protected containerStyleMap = styleMap({
|
||||||
@@ -127,25 +123,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
|
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
|
||||||
);
|
);
|
||||||
|
|
||||||
// this is required to prevent iframe from capturing pointer events
|
|
||||||
this.disposables.add(
|
|
||||||
this.selected$.subscribe(selected => {
|
|
||||||
this._showOverlay = this._isResizing || this._isDragging || !selected;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// this is required to prevent iframe from capturing pointer events
|
|
||||||
this.handleEvent('dragStart', () => {
|
|
||||||
this._isDragging = true;
|
|
||||||
this._showOverlay =
|
|
||||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handleEvent('dragEnd', () => {
|
|
||||||
this._isDragging = false;
|
|
||||||
this._showOverlay =
|
|
||||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
@@ -166,7 +143,9 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
this._selectBlock();
|
if (!this.selected$.peek()) {
|
||||||
|
this._selectBlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override renderBlock() {
|
override renderBlock() {
|
||||||
@@ -190,13 +169,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
${embedView
|
${embedView
|
||||||
? html`<div class="affine-attachment-embed-container">
|
? html`<div class="affine-attachment-embed-container">
|
||||||
${embedView}
|
${embedView}
|
||||||
|
|
||||||
<div
|
|
||||||
class=${classMap({
|
|
||||||
'affine-attachment-iframe-overlay': true,
|
|
||||||
hide: !this._showOverlay,
|
|
||||||
})}
|
|
||||||
></div>
|
|
||||||
</div>`
|
</div>`
|
||||||
: html`<div
|
: html`<div
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
@@ -227,9 +199,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@state()
|
|
||||||
protected accessor _showOverlay = true;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor allowEmbed = false;
|
accessor allowEmbed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent(
|
|||||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
this._disposables.add(
|
|
||||||
this.slots.elementResizeStart.subscribe(() => {
|
|
||||||
this._isResizing = true;
|
|
||||||
this._showOverlay = true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this._disposables.add(
|
|
||||||
this.slots.elementResizeEnd.subscribe(() => {
|
|
||||||
this._isResizing = false;
|
|
||||||
this._showOverlay =
|
|
||||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
override onClick(_: MouseEvent) {
|
override onClick(_: MouseEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export const attachmentSlashMenuConfig: SlashMenuConfig = {
|
|||||||
std.host,
|
std.host,
|
||||||
[file],
|
[file],
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
model
|
model,
|
||||||
|
'after'
|
||||||
);
|
);
|
||||||
if (model.text?.length === 0) {
|
if (model.text?.length === 0) {
|
||||||
std.store.deleteBlock(model);
|
std.store.deleteBlock(model);
|
||||||
@@ -60,8 +61,7 @@ export const attachmentSlashMenuConfig: SlashMenuConfig = {
|
|||||||
[file],
|
[file],
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
model,
|
model,
|
||||||
'after',
|
'after'
|
||||||
true
|
|
||||||
);
|
);
|
||||||
if (model.text?.length === 0) {
|
if (model.text?.length === 0) {
|
||||||
std.store.deleteBlock(model);
|
std.store.deleteBlock(model);
|
||||||
|
|||||||
@@ -136,16 +136,4 @@ export const styles = css`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-attachment-iframe-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.affine-attachment-iframe-overlay.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const AttachmentBlockSchema = defineBlockSchema({
|
|||||||
'affine:paragraph',
|
'affine:paragraph',
|
||||||
'affine:list',
|
'affine:list',
|
||||||
],
|
],
|
||||||
|
children: ['@attachment-viewer'],
|
||||||
},
|
},
|
||||||
transformer: transformerConfigs =>
|
transformer: transformerConfigs =>
|
||||||
new AttachmentBlockTransformer(transformerConfigs),
|
new AttachmentBlockTransformer(transformerConfigs),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const CalloutBlockSchema = defineBlockSchema({
|
|||||||
'affine:paragraph',
|
'affine:paragraph',
|
||||||
'affine:list',
|
'affine:list',
|
||||||
'affine:edgeless-text',
|
'affine:edgeless-text',
|
||||||
|
'affine:transcription',
|
||||||
],
|
],
|
||||||
children: ['affine:paragraph'],
|
children: ['affine:paragraph'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from './paragraph/index.js';
|
|||||||
export * from './root/index.js';
|
export * from './root/index.js';
|
||||||
export * from './surface-ref/index.js';
|
export * from './surface-ref/index.js';
|
||||||
export * from './table';
|
export * from './table';
|
||||||
|
export * from './transcription';
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const ParagraphBlockSchema = defineBlockSchema({
|
|||||||
'affine:list',
|
'affine:list',
|
||||||
'affine:edgeless-text',
|
'affine:edgeless-text',
|
||||||
'affine:callout',
|
'affine:callout',
|
||||||
|
'affine:transcription',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
toModel: () => new ParagraphBlockModel(),
|
toModel: () => new ParagraphBlockModel(),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './transcription-model.js';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
BlockModel,
|
||||||
|
BlockSchemaExtension,
|
||||||
|
defineBlockSchema,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
|
||||||
|
export const TranscriptionBlockFlavour = 'affine:transcription';
|
||||||
|
|
||||||
|
export const TranscriptionBlockSchema = defineBlockSchema({
|
||||||
|
flavour: TranscriptionBlockFlavour,
|
||||||
|
props: () => ({
|
||||||
|
transcription: {},
|
||||||
|
jobId: '',
|
||||||
|
}),
|
||||||
|
metadata: {
|
||||||
|
version: 1,
|
||||||
|
role: 'attachment-viewer',
|
||||||
|
parent: ['affine:attachment'],
|
||||||
|
children: ['affine:callout'],
|
||||||
|
},
|
||||||
|
toModel: () => new TranscriptionBlockModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TranscriptionBlockProps = {
|
||||||
|
transcription: Record<string, any>;
|
||||||
|
jobId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TranscriptionBlockModel extends BlockModel<TranscriptionBlockProps> {}
|
||||||
|
|
||||||
|
export const TranscriptionBlockSchemaExtension = BlockSchemaExtension(
|
||||||
|
TranscriptionBlockSchema
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@ export * from './app-config-storage';
|
|||||||
export * from './atom';
|
export * from './atom';
|
||||||
export * from './framework';
|
export * from './framework';
|
||||||
export * from './livedata';
|
export * from './livedata';
|
||||||
|
export * from './media';
|
||||||
export * from './orm';
|
export * from './orm';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './sync';
|
export * from './sync';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Attachment block audio media.
|
||||||
|
* blockId/docId/workspaceId are used to identify the source of the media
|
||||||
|
* to control the exclusivity playback state of audio across the whole application.
|
||||||
|
*/
|
||||||
|
export interface AttachmentBlockAudioMedia {
|
||||||
|
blobId: string; // aka sourceId
|
||||||
|
blockId: string;
|
||||||
|
docId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioMediaDescriptor {
|
||||||
|
key: AudioMediaKey;
|
||||||
|
tabId: string | null;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
blobId: string; // aka sourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceId/docId/blockId/blobId
|
||||||
|
export type AudioMediaKey = `${string}/${string}/${string}/${string}`;
|
||||||
|
|
||||||
|
export const attachmentBlockAudioMediaKey = (
|
||||||
|
media: AttachmentBlockAudioMedia
|
||||||
|
): AudioMediaKey => {
|
||||||
|
return `${media.workspaceId}/${media.docId}/${media.blockId}/${media.blobId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseAudioMediaKey = (
|
||||||
|
key: AudioMediaKey
|
||||||
|
): AttachmentBlockAudioMedia => {
|
||||||
|
const [workspaceId, docId, blockId, blobId] = key.split('/');
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
blockId,
|
||||||
|
blobId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = 'idle' | 'playing' | 'paused' | 'stopped';
|
||||||
|
|
||||||
|
export interface MediaStats {
|
||||||
|
key: AudioMediaKey;
|
||||||
|
tabId: string | null;
|
||||||
|
duration: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
waveform: number[]; // for drawing waveform, maxmium of 1000 samples
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackState {
|
||||||
|
key: AudioMediaKey;
|
||||||
|
tabId: string | null;
|
||||||
|
state: State;
|
||||||
|
/**
|
||||||
|
* Whenever the user seek the media, the startSeekOffset will be updated
|
||||||
|
*/
|
||||||
|
seekOffset: number;
|
||||||
|
/**
|
||||||
|
* Whenever the media state is updated.
|
||||||
|
* the updateTime will be updated. It is in milliseconds (unix timestamp).
|
||||||
|
* The current playback position (0-based, in seconds) is calculated by `seekOffset + (Date.now() - updateTime) / 1000 * rate`
|
||||||
|
*/
|
||||||
|
updateTime: number;
|
||||||
|
/**
|
||||||
|
* the playback rate
|
||||||
|
* Not implemented yet. Always 1.0.
|
||||||
|
*/
|
||||||
|
// rate: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Unreachable } from '@affine/env/constant';
|
import { Unreachable } from '@affine/env/constant';
|
||||||
|
|
||||||
export interface RcRef<T> {
|
export interface RcRef<T> extends Disposable {
|
||||||
obj: T;
|
obj: T;
|
||||||
release: () => void;
|
release: () => void;
|
||||||
}
|
}
|
||||||
@@ -21,17 +21,19 @@ export class ObjectPool<Key, T> {
|
|||||||
if (exist) {
|
if (exist) {
|
||||||
exist.rc++;
|
exist.rc++;
|
||||||
let released = false;
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
// avoid double release
|
||||||
|
if (released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
released = true;
|
||||||
|
exist.rc--;
|
||||||
|
this.requestGc();
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
obj: exist.obj,
|
obj: exist.obj,
|
||||||
release: () => {
|
release,
|
||||||
// avoid double release
|
[Symbol.dispose]: release,
|
||||||
if (released) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
released = true;
|
|
||||||
exist.rc--;
|
|
||||||
this.requestGc();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@affine/component": "workspace:*",
|
"@affine/component": "workspace:*",
|
||||||
"@affine/core": "workspace:*",
|
"@affine/core": "workspace:*",
|
||||||
|
"@affine/debug": "workspace:*",
|
||||||
"@affine/electron-api": "workspace:*",
|
"@affine/electron-api": "workspace:*",
|
||||||
"@affine/i18n": "workspace:*",
|
"@affine/i18n": "workspace:*",
|
||||||
"@affine/nbstore": "workspace:*",
|
"@affine/nbstore": "workspace:*",
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"uuid": "^11.0.3"
|
"uuid": "^11.0.3",
|
||||||
|
"webm-muxer": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-tools/utils": "workspace:*",
|
"@affine-tools/utils": "workspace:*",
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
import type { DocProps } from '@affine/core/blocksuite/initialization';
|
|
||||||
import { AffineContext } from '@affine/core/components/context';
|
|
||||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
|
||||||
import { AppContainer } from '@affine/core/desktop/components/app-container';
|
|
||||||
import { router } from '@affine/core/desktop/router';
|
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
|
||||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
|
||||||
import { configureDesktopBackupModule } from '@affine/core/modules/backup';
|
|
||||||
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
|
||||||
import {
|
|
||||||
configureDesktopApiModule,
|
|
||||||
DesktopApiService,
|
|
||||||
} from '@affine/core/modules/desktop-api';
|
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
|
||||||
import { DocsService } from '@affine/core/modules/doc';
|
|
||||||
import {
|
|
||||||
configureSpellCheckSettingModule,
|
|
||||||
EditorSettingService,
|
|
||||||
} from '@affine/core/modules/editor-setting';
|
|
||||||
import { configureFindInPageModule } from '@affine/core/modules/find-in-page';
|
|
||||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
|
||||||
import { I18nProvider } from '@affine/core/modules/i18n';
|
|
||||||
import { JournalService } from '@affine/core/modules/journal';
|
|
||||||
import { LifecycleService } from '@affine/core/modules/lifecycle';
|
|
||||||
import {
|
|
||||||
configureElectronStateStorageImpls,
|
|
||||||
NbstoreProvider,
|
|
||||||
} from '@affine/core/modules/storage';
|
|
||||||
import {
|
|
||||||
ClientSchemeProvider,
|
|
||||||
PopupWindowProvider,
|
|
||||||
} from '@affine/core/modules/url';
|
|
||||||
import {
|
|
||||||
configureDesktopWorkbenchModule,
|
|
||||||
WorkbenchService,
|
|
||||||
} from '@affine/core/modules/workbench';
|
|
||||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
|
||||||
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
|
||||||
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
|
|
||||||
import { apis, events } from '@affine/electron-api';
|
|
||||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
|
||||||
import type { AttachmentBlockProps } from '@blocksuite/affine/model';
|
|
||||||
import { Text } from '@blocksuite/affine/store';
|
|
||||||
import { CacheProvider } from '@emotion/react';
|
|
||||||
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
|
|
||||||
import { OpClient } from '@toeverything/infra/op';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
import { DesktopThemeSync } from './theme-sync';
|
|
||||||
|
|
||||||
const storeManagerClient = createStoreManagerClient();
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
storeManagerClient.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
const desktopWhiteList = [
|
|
||||||
'/open-app/signin-redirect',
|
|
||||||
'/open-app/url',
|
|
||||||
'/upgrade-success',
|
|
||||||
'/ai-upgrade-success',
|
|
||||||
'/share',
|
|
||||||
'/oauth',
|
|
||||||
'/magic-link',
|
|
||||||
];
|
|
||||||
if (
|
|
||||||
!BUILD_CONFIG.isElectron &&
|
|
||||||
BUILD_CONFIG.debug &&
|
|
||||||
desktopWhiteList.every(path => !location.pathname.startsWith(path))
|
|
||||||
) {
|
|
||||||
document.body.innerHTML = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run electron entry in browser.</h1>`;
|
|
||||||
throw new Error('Wrong distribution');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = createEmotionCache();
|
|
||||||
|
|
||||||
const future = {
|
|
||||||
v7_startTransition: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const framework = new Framework();
|
|
||||||
configureCommonModules(framework);
|
|
||||||
configureElectronStateStorageImpls(framework);
|
|
||||||
configureBrowserWorkspaceFlavours(framework);
|
|
||||||
configureDesktopWorkbenchModule(framework);
|
|
||||||
configureAppTabsHeaderModule(framework);
|
|
||||||
configureFindInPageModule(framework);
|
|
||||||
configureDesktopApiModule(framework);
|
|
||||||
configureSpellCheckSettingModule(framework);
|
|
||||||
configureDesktopBackupModule(framework);
|
|
||||||
framework.impl(NbstoreProvider, {
|
|
||||||
openStore(key, options) {
|
|
||||||
const { store, dispose } = storeManagerClient.open(key, options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
dispose: () => {
|
|
||||||
dispose();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
framework.impl(PopupWindowProvider, p => {
|
|
||||||
const apis = p.get(DesktopApiService).api;
|
|
||||||
return {
|
|
||||||
open: (url: string) => {
|
|
||||||
apis.handler.ui.openExternal(url).catch(e => {
|
|
||||||
console.error('Failed to open external URL', e);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
framework.impl(ClientSchemeProvider, p => {
|
|
||||||
const appInfo = p.get(DesktopApiService).appInfo;
|
|
||||||
return {
|
|
||||||
getClientScheme() {
|
|
||||||
return appInfo?.scheme;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
framework.impl(ValidatorProvider, p => {
|
|
||||||
const apis = p.get(DesktopApiService).api;
|
|
||||||
return {
|
|
||||||
async validate(_challenge, resource) {
|
|
||||||
const token = await apis.handler.ui.getChallengeResponse(resource);
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Challenge failed');
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const frameworkProvider = framework.provider();
|
|
||||||
|
|
||||||
// setup application lifecycle events, and emit application start event
|
|
||||||
window.addEventListener('focus', () => {
|
|
||||||
frameworkProvider.get(LifecycleService).applicationFocus();
|
|
||||||
});
|
|
||||||
frameworkProvider.get(LifecycleService).applicationStart();
|
|
||||||
window.addEventListener('unload', () => {
|
|
||||||
frameworkProvider
|
|
||||||
.get(DesktopApiService)
|
|
||||||
.api.handler.ui.pingAppLayoutReady(false)
|
|
||||||
.catch(console.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
function getCurrentWorkspace() {
|
|
||||||
const currentWorkspaceId = frameworkProvider
|
|
||||||
.get(GlobalContextService)
|
|
||||||
.globalContext.workspaceId.get();
|
|
||||||
const workspacesService = frameworkProvider.get(WorkspacesService);
|
|
||||||
const workspaceRef = currentWorkspaceId
|
|
||||||
? workspacesService.openByWorkspaceId(currentWorkspaceId)
|
|
||||||
: null;
|
|
||||||
if (!workspaceRef) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { workspace, dispose } = workspaceRef;
|
|
||||||
|
|
||||||
return {
|
|
||||||
workspace,
|
|
||||||
dispose,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
events?.applicationMenu.openAboutPageInSettingModal(() => {
|
|
||||||
const currentWorkspace = getCurrentWorkspace();
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { workspace, dispose } = currentWorkspace;
|
|
||||||
workspace.scope.get(WorkspaceDialogService).open('setting', {
|
|
||||||
activeTab: 'about',
|
|
||||||
});
|
|
||||||
dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
events?.applicationMenu.onNewPageAction(type => {
|
|
||||||
apis?.ui
|
|
||||||
.isActiveTab()
|
|
||||||
.then(isActive => {
|
|
||||||
if (!isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentWorkspace = getCurrentWorkspace();
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { workspace, dispose } = currentWorkspace;
|
|
||||||
const editorSettingService = frameworkProvider.get(EditorSettingService);
|
|
||||||
const docsService = workspace.scope.get(DocsService);
|
|
||||||
const editorSetting = editorSettingService.editorSetting;
|
|
||||||
|
|
||||||
const docProps = {
|
|
||||||
note: editorSetting.get('affine:note'),
|
|
||||||
};
|
|
||||||
const page = docsService.createDoc({ docProps, primaryMode: type });
|
|
||||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
|
||||||
dispose();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
events?.recording.onRecordingStatusChanged(status => {
|
|
||||||
(async () => {
|
|
||||||
if ((await apis?.ui.isActiveTab()) && status?.status === 'stopped') {
|
|
||||||
const currentWorkspace = getCurrentWorkspace();
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { workspace, dispose } = currentWorkspace;
|
|
||||||
const editorSettingService = frameworkProvider.get(EditorSettingService);
|
|
||||||
const docsService = workspace.scope.get(DocsService);
|
|
||||||
const editorSetting = editorSettingService.editorSetting;
|
|
||||||
|
|
||||||
const docProps: DocProps = {
|
|
||||||
note: editorSetting.get('affine:note'),
|
|
||||||
page: {
|
|
||||||
title: new Text(
|
|
||||||
'Recording ' +
|
|
||||||
(status.appGroup?.name ?? 'System Audio') +
|
|
||||||
' ' +
|
|
||||||
new Date(status.startTime).toISOString()
|
|
||||||
),
|
|
||||||
},
|
|
||||||
onStoreLoad: (doc, { noteId }) => {
|
|
||||||
(async () => {
|
|
||||||
const data = await apis?.recording.saveRecording(status.id);
|
|
||||||
if (!data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blob = new Blob([data], { type: 'audio/mp3' });
|
|
||||||
const blobId = await doc.workspace.blobSync.set(blob);
|
|
||||||
const attachmentProps: Partial<AttachmentBlockProps> = {
|
|
||||||
name: 'Recording',
|
|
||||||
size: blob.size,
|
|
||||||
type: 'audio/mp3',
|
|
||||||
sourceId: blobId,
|
|
||||||
embed: true,
|
|
||||||
};
|
|
||||||
doc.addBlock('affine:attachment', attachmentProps, noteId);
|
|
||||||
})().catch(console.error);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const page = docsService.createDoc({ docProps, primaryMode: 'page' });
|
|
||||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
|
||||||
|
|
||||||
dispose();
|
|
||||||
}
|
|
||||||
})().catch(console.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
events?.applicationMenu.onOpenJournal(() => {
|
|
||||||
const currentWorkspace = getCurrentWorkspace();
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { workspace, dispose } = currentWorkspace;
|
|
||||||
|
|
||||||
const workbench = workspace.scope.get(WorkbenchService).workbench;
|
|
||||||
const journalService = workspace.scope.get(JournalService);
|
|
||||||
const docId = journalService.ensureJournalByDate(new Date()).id;
|
|
||||||
workbench.openDoc(docId);
|
|
||||||
|
|
||||||
dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<FrameworkRoot framework={frameworkProvider}>
|
|
||||||
<CacheProvider value={cache}>
|
|
||||||
<I18nProvider>
|
|
||||||
<AffineContext store={getCurrentStore()}>
|
|
||||||
<DesktopThemeSync />
|
|
||||||
<RouterProvider
|
|
||||||
fallbackElement={<AppContainer fallback />}
|
|
||||||
router={router}
|
|
||||||
future={future}
|
|
||||||
/>
|
|
||||||
{environment.isWindows && (
|
|
||||||
<div style={{ position: 'fixed', right: 0, top: 0, zIndex: 5 }}>
|
|
||||||
<WindowsAppControls />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AffineContext>
|
|
||||||
</I18nProvider>
|
|
||||||
</CacheProvider>
|
|
||||||
</FrameworkRoot>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStoreManagerClient() {
|
|
||||||
const { port1: portForOpClient, port2: portForWorker } = new MessageChannel();
|
|
||||||
let portFromWorker: MessagePort | null = null;
|
|
||||||
let portId = uuid();
|
|
||||||
|
|
||||||
const handleMessage = (ev: MessageEvent) => {
|
|
||||||
if (
|
|
||||||
ev.data.type === 'electron:worker-connect' &&
|
|
||||||
ev.data.portId === portId
|
|
||||||
) {
|
|
||||||
portFromWorker = ev.ports[0];
|
|
||||||
// connect portForWorker and portFromWorker
|
|
||||||
portFromWorker.addEventListener('message', ev => {
|
|
||||||
portForWorker.postMessage(ev.data, [...ev.ports]);
|
|
||||||
});
|
|
||||||
portForWorker.addEventListener('message', ev => {
|
|
||||||
// oxlint-disable-next-line no-non-null-assertion
|
|
||||||
portFromWorker!.postMessage(ev.data, [...ev.ports]);
|
|
||||||
});
|
|
||||||
portForWorker.start();
|
|
||||||
portFromWorker.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line no-non-null-assertion
|
|
||||||
apis!.worker.connectWorker('affine-shared-worker', portId).catch(err => {
|
|
||||||
console.error('failed to connect worker', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
const storeManager = new StoreManagerClient(new OpClient(portForOpClient));
|
|
||||||
portForOpClient.start();
|
|
||||||
return storeManager;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { AffineContext } from '@affine/core/components/context';
|
||||||
|
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||||
|
import { AppContainer } from '@affine/core/desktop/components/app-container';
|
||||||
|
import { router } from '@affine/core/desktop/router';
|
||||||
|
import { I18nProvider } from '@affine/core/modules/i18n';
|
||||||
|
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
|
||||||
|
import { CacheProvider } from '@emotion/react';
|
||||||
|
import { FrameworkRoot, getCurrentStore } from '@toeverything/infra';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { setupEffects } from './effects';
|
||||||
|
import { DesktopThemeSync } from './theme-sync';
|
||||||
|
|
||||||
|
const { frameworkProvider } = setupEffects();
|
||||||
|
|
||||||
|
const desktopWhiteList = [
|
||||||
|
'/open-app/signin-redirect',
|
||||||
|
'/open-app/url',
|
||||||
|
'/upgrade-success',
|
||||||
|
'/ai-upgrade-success',
|
||||||
|
'/share',
|
||||||
|
'/oauth',
|
||||||
|
'/magic-link',
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!BUILD_CONFIG.isElectron &&
|
||||||
|
BUILD_CONFIG.debug &&
|
||||||
|
desktopWhiteList.every(path => !location.pathname.startsWith(path))
|
||||||
|
) {
|
||||||
|
document.body.innerHTML = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run electron entry in browser.</h1>`;
|
||||||
|
throw new Error('Wrong distribution');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = createEmotionCache();
|
||||||
|
|
||||||
|
const future = {
|
||||||
|
v7_startTransition: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<FrameworkRoot framework={frameworkProvider}>
|
||||||
|
<CacheProvider value={cache}>
|
||||||
|
<I18nProvider>
|
||||||
|
<AffineContext store={getCurrentStore()}>
|
||||||
|
<DesktopThemeSync />
|
||||||
|
<RouterProvider
|
||||||
|
fallbackElement={<AppContainer fallback />}
|
||||||
|
router={router}
|
||||||
|
future={future}
|
||||||
|
/>
|
||||||
|
{environment.isWindows && (
|
||||||
|
<div style={{ position: 'fixed', right: 0, top: 0, zIndex: 5 }}>
|
||||||
|
<WindowsAppControls />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AffineContext>
|
||||||
|
</I18nProvider>
|
||||||
|
</CacheProvider>
|
||||||
|
</FrameworkRoot>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
|
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||||
|
import { JournalService } from '@affine/core/modules/journal';
|
||||||
|
import { LifecycleService } from '@affine/core/modules/lifecycle';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { apis, events } from '@affine/electron-api';
|
||||||
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { setupRecordingEvents } from './recording';
|
||||||
|
import { getCurrentWorkspace } from './utils';
|
||||||
|
|
||||||
|
export function setupEvents(frameworkProvider: FrameworkProvider) {
|
||||||
|
// setup application lifecycle events, and emit application start event
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
frameworkProvider.get(LifecycleService).applicationFocus();
|
||||||
|
});
|
||||||
|
frameworkProvider.get(LifecycleService).applicationStart();
|
||||||
|
window.addEventListener('unload', () => {
|
||||||
|
frameworkProvider
|
||||||
|
.get(DesktopApiService)
|
||||||
|
.api.handler.ui.pingAppLayoutReady(false)
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
events?.applicationMenu.openAboutPageInSettingModal(() => {
|
||||||
|
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace } = currentWorkspace;
|
||||||
|
workspace.scope.get(WorkspaceDialogService).open('setting', {
|
||||||
|
activeTab: 'about',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
events?.applicationMenu.onNewPageAction(type => {
|
||||||
|
apis?.ui
|
||||||
|
.isActiveTab()
|
||||||
|
.then(isActive => {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace } = currentWorkspace;
|
||||||
|
const editorSettingService =
|
||||||
|
frameworkProvider.get(EditorSettingService);
|
||||||
|
const docsService = workspace.scope.get(DocsService);
|
||||||
|
const editorSetting = editorSettingService.editorSetting;
|
||||||
|
|
||||||
|
const docProps = {
|
||||||
|
note: editorSetting.get('affine:note'),
|
||||||
|
};
|
||||||
|
const page = docsService.createDoc({ docProps, primaryMode: type });
|
||||||
|
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
events?.applicationMenu.onOpenJournal(() => {
|
||||||
|
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace, dispose } = currentWorkspace;
|
||||||
|
|
||||||
|
const workbench = workspace.scope.get(WorkbenchService).workbench;
|
||||||
|
const journalService = workspace.scope.get(JournalService);
|
||||||
|
const docId = journalService.ensureJournalByDate(new Date()).id;
|
||||||
|
workbench.openDoc(docId);
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
setupRecordingEvents(frameworkProvider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { setupEvents } from './events';
|
||||||
|
import { setupModules } from './modules';
|
||||||
|
import { setupStoreManager } from './store-manager';
|
||||||
|
|
||||||
|
export function setupEffects() {
|
||||||
|
const { framework, frameworkProvider } = setupModules();
|
||||||
|
setupStoreManager(framework);
|
||||||
|
setupEvents(frameworkProvider);
|
||||||
|
return { framework, frameworkProvider };
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
|
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||||
|
import { configureDesktopBackupModule } from '@affine/core/modules/backup';
|
||||||
|
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
||||||
|
import {
|
||||||
|
configureDesktopApiModule,
|
||||||
|
DesktopApiService,
|
||||||
|
} from '@affine/core/modules/desktop-api';
|
||||||
|
import { configureSpellCheckSettingModule } from '@affine/core/modules/editor-setting';
|
||||||
|
import { configureFindInPageModule } from '@affine/core/modules/find-in-page';
|
||||||
|
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
||||||
|
import {
|
||||||
|
ClientSchemeProvider,
|
||||||
|
PopupWindowProvider,
|
||||||
|
} from '@affine/core/modules/url';
|
||||||
|
import { configureDesktopWorkbenchModule } from '@affine/core/modules/workbench';
|
||||||
|
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
||||||
|
import { Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
|
export function setupModules() {
|
||||||
|
const framework = new Framework();
|
||||||
|
configureCommonModules(framework);
|
||||||
|
configureElectronStateStorageImpls(framework);
|
||||||
|
configureBrowserWorkspaceFlavours(framework);
|
||||||
|
configureDesktopWorkbenchModule(framework);
|
||||||
|
configureAppTabsHeaderModule(framework);
|
||||||
|
configureFindInPageModule(framework);
|
||||||
|
configureDesktopApiModule(framework);
|
||||||
|
configureSpellCheckSettingModule(framework);
|
||||||
|
configureDesktopBackupModule(framework);
|
||||||
|
|
||||||
|
framework.impl(PopupWindowProvider, p => {
|
||||||
|
const apis = p.get(DesktopApiService).api;
|
||||||
|
return {
|
||||||
|
open: (url: string) => {
|
||||||
|
apis.handler.ui.openExternal(url).catch(e => {
|
||||||
|
console.error('Failed to open external URL', e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
framework.impl(ClientSchemeProvider, p => {
|
||||||
|
const appInfo = p.get(DesktopApiService).appInfo;
|
||||||
|
return {
|
||||||
|
getClientScheme() {
|
||||||
|
return appInfo?.scheme;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
framework.impl(ValidatorProvider, p => {
|
||||||
|
const apis = p.get(DesktopApiService).api;
|
||||||
|
return {
|
||||||
|
async validate(_challenge, resource) {
|
||||||
|
const token = await apis.handler.ui.getChallengeResponse(resource);
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Challenge failed');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const frameworkProvider = framework.provider();
|
||||||
|
|
||||||
|
return { framework, frameworkProvider };
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { DocProps } from '@affine/core/blocksuite/initialization';
|
||||||
|
import { DocsService } from '@affine/core/modules/doc';
|
||||||
|
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||||
|
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { apis, events } from '@affine/electron-api';
|
||||||
|
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||||
|
import { Text } from '@blocksuite/affine/store';
|
||||||
|
import type { BlobEngine } from '@blocksuite/affine/sync';
|
||||||
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
|
||||||
|
|
||||||
|
import { getCurrentWorkspace } from './utils';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('electron-renderer:recording');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes raw audio data to Opus in WebM container.
|
||||||
|
*/
|
||||||
|
async function encodeRawBufferToOpus({
|
||||||
|
filepath,
|
||||||
|
sampleRate,
|
||||||
|
numberOfChannels,
|
||||||
|
}: {
|
||||||
|
filepath: string;
|
||||||
|
sampleRate: number;
|
||||||
|
numberOfChannels: number;
|
||||||
|
}): Promise<Uint8Array> {
|
||||||
|
// Use streams to process audio data incrementally
|
||||||
|
const response = await fetch(new URL(filepath, location.origin));
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Opus encoder
|
||||||
|
const encodedChunks: EncodedAudioChunk[] = [];
|
||||||
|
const encoder = new AudioEncoder({
|
||||||
|
output: chunk => {
|
||||||
|
encodedChunks.push(chunk);
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
throw new Error(`Encoding error: ${err}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure Opus encoder
|
||||||
|
encoder.configure({
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
numberOfChannels: numberOfChannels,
|
||||||
|
bitrate: 96000, // 96 kbps is good for stereo audio
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the stream
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
let offset = 0;
|
||||||
|
const CHUNK_SIZE = numberOfChannels * 1024; // Process 1024 samples per channel at a time
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
// Convert the chunk to Float32Array
|
||||||
|
const float32Data = new Float32Array(value.buffer);
|
||||||
|
|
||||||
|
// Process in smaller chunks to avoid large frames
|
||||||
|
for (let i = 0; i < float32Data.length; i += CHUNK_SIZE) {
|
||||||
|
const chunkSize = Math.min(CHUNK_SIZE, float32Data.length - i);
|
||||||
|
const chunk = float32Data.subarray(i, i + chunkSize);
|
||||||
|
|
||||||
|
// Create and encode frame
|
||||||
|
const frame = new AudioData({
|
||||||
|
format: 'f32',
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
numberOfFrames: chunk.length / numberOfChannels,
|
||||||
|
numberOfChannels: numberOfChannels,
|
||||||
|
timestamp: (offset * 1000000) / sampleRate, // timestamp in microseconds
|
||||||
|
data: chunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
encoder.encode(frame);
|
||||||
|
frame.close();
|
||||||
|
|
||||||
|
offset += chunk.length / numberOfChannels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await encoder.flush();
|
||||||
|
encoder.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encodedChunks.length === 0) {
|
||||||
|
throw new Error('No chunks were produced during encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebM muxer
|
||||||
|
const target = new ArrayBufferTarget();
|
||||||
|
const muxer = new Muxer({
|
||||||
|
target,
|
||||||
|
audio: {
|
||||||
|
codec: 'A_OPUS',
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
numberOfChannels: numberOfChannels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all chunks to the muxer
|
||||||
|
for (const chunk of encodedChunks) {
|
||||||
|
muxer.addAudioChunk(chunk, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize and get WebM container
|
||||||
|
muxer.finalize();
|
||||||
|
const { buffer: webmBuffer } = target;
|
||||||
|
|
||||||
|
return new Uint8Array(webmBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecordingBlob(
|
||||||
|
blobEngine: BlobEngine,
|
||||||
|
recording: {
|
||||||
|
id: number;
|
||||||
|
filepath: string;
|
||||||
|
sampleRate: number;
|
||||||
|
numberOfChannels: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
logger.debug('Saving recording', recording.id);
|
||||||
|
const opusBuffer = await encodeRawBufferToOpus({
|
||||||
|
filepath: recording.filepath,
|
||||||
|
sampleRate: recording.sampleRate,
|
||||||
|
numberOfChannels: recording.numberOfChannels,
|
||||||
|
});
|
||||||
|
const blob = new Blob([opusBuffer], {
|
||||||
|
type: 'audio/webm',
|
||||||
|
});
|
||||||
|
const blobId = await blobEngine.set(blob);
|
||||||
|
logger.debug('Recording saved', blobId);
|
||||||
|
return { blob, blobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||||
|
events?.recording.onRecordingStatusChanged(status => {
|
||||||
|
(async () => {
|
||||||
|
if ((await apis?.ui.isActiveTab()) && status?.status === 'stopped') {
|
||||||
|
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace } = currentWorkspace;
|
||||||
|
const editorSettingService =
|
||||||
|
frameworkProvider.get(EditorSettingService);
|
||||||
|
const docsService = workspace.scope.get(DocsService);
|
||||||
|
const editorSetting = editorSettingService.editorSetting;
|
||||||
|
|
||||||
|
const docProps: DocProps = {
|
||||||
|
note: editorSetting.get('affine:note'),
|
||||||
|
page: {
|
||||||
|
title: new Text(
|
||||||
|
'Recording ' +
|
||||||
|
(status.appGroup?.name ?? 'System Audio') +
|
||||||
|
' ' +
|
||||||
|
new Date(status.startTime).toISOString()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
onStoreLoad: (doc, { noteId }) => {
|
||||||
|
(async () => {
|
||||||
|
const recording = await apis?.recording.getRecording(status.id);
|
||||||
|
if (!recording) {
|
||||||
|
logger.error('Failed to save recording');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// name + timestamp(readable) + extension
|
||||||
|
const attachmentName =
|
||||||
|
(status.appGroup?.name ?? 'System Audio') +
|
||||||
|
' ' +
|
||||||
|
new Date(status.startTime).toISOString() +
|
||||||
|
'.webm';
|
||||||
|
|
||||||
|
// add size and sourceId to the attachment later
|
||||||
|
const attachmentId = doc.addBlock(
|
||||||
|
'affine:attachment',
|
||||||
|
{
|
||||||
|
name: attachmentName,
|
||||||
|
type: 'audio/webm',
|
||||||
|
},
|
||||||
|
noteId
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = doc.getBlock(attachmentId)
|
||||||
|
?.model as AttachmentBlockModel;
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
// it takes a while to save the blob, so we show the attachment first
|
||||||
|
const { blobId, blob } = await saveRecordingBlob(
|
||||||
|
doc.workspace.blobSync,
|
||||||
|
recording
|
||||||
|
);
|
||||||
|
|
||||||
|
model.props.size = blob.size;
|
||||||
|
model.props.sourceId = blobId;
|
||||||
|
model.props.embed = true;
|
||||||
|
|
||||||
|
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace } = currentWorkspace;
|
||||||
|
using audioAttachment = workspace.scope
|
||||||
|
.get(AudioAttachmentService)
|
||||||
|
.get(model);
|
||||||
|
audioAttachment?.obj.transcribe();
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const page = docsService.createDoc({ docProps, primaryMode: 'page' });
|
||||||
|
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NbstoreProvider } from '@affine/core/modules/storage';
|
||||||
|
import { apis } from '@affine/electron-api';
|
||||||
|
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||||
|
import type { Framework } from '@toeverything/infra';
|
||||||
|
import { OpClient } from '@toeverything/infra/op';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
function createStoreManagerClient() {
|
||||||
|
const { port1: portForOpClient, port2: portForWorker } = new MessageChannel();
|
||||||
|
let portFromWorker: MessagePort | null = null;
|
||||||
|
let portId = uuid();
|
||||||
|
|
||||||
|
const handleMessage = (ev: MessageEvent) => {
|
||||||
|
if (
|
||||||
|
ev.data.type === 'electron:worker-connect' &&
|
||||||
|
ev.data.portId === portId
|
||||||
|
) {
|
||||||
|
portFromWorker = ev.ports[0];
|
||||||
|
// connect portForWorker and portFromWorker
|
||||||
|
portFromWorker.addEventListener('message', ev => {
|
||||||
|
portForWorker.postMessage(ev.data, [...ev.ports]);
|
||||||
|
});
|
||||||
|
portForWorker.addEventListener('message', ev => {
|
||||||
|
// oxlint-disable-next-line no-non-null-assertion
|
||||||
|
portFromWorker!.postMessage(ev.data, [...ev.ports]);
|
||||||
|
});
|
||||||
|
portForWorker.start();
|
||||||
|
portFromWorker.start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// oxlint-disable-next-line no-non-null-assertion
|
||||||
|
apis!.worker.connectWorker('affine-shared-worker', portId).catch(err => {
|
||||||
|
console.error('failed to connect worker', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeManager = new StoreManagerClient(new OpClient(portForOpClient));
|
||||||
|
portForOpClient.start();
|
||||||
|
return storeManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupStoreManager(framework: Framework) {
|
||||||
|
const storeManagerClient = createStoreManagerClient();
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
storeManagerClient.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
framework.impl(NbstoreProvider, {
|
||||||
|
openStore(key, options) {
|
||||||
|
const { store, dispose } = storeManagerClient.open(key, options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
dispose: () => {
|
||||||
|
dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||||
|
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||||
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
|
||||||
|
export function getCurrentWorkspace(frameworkProvider: FrameworkProvider) {
|
||||||
|
const currentWorkspaceId = frameworkProvider
|
||||||
|
.get(GlobalContextService)
|
||||||
|
.globalContext.workspaceId.get();
|
||||||
|
const workspacesService = frameworkProvider.get(WorkspacesService);
|
||||||
|
const workspaceRef = currentWorkspaceId
|
||||||
|
? workspacesService.openByWorkspaceId(currentWorkspaceId)
|
||||||
|
: null;
|
||||||
|
if (!workspaceRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspace, dispose } = workspaceRef;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
dispose,
|
||||||
|
[Symbol.dispose]: dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import '@affine/core/bootstrap/electron';
|
import '@affine/core/bootstrap/electron';
|
||||||
import '@affine/component/theme';
|
import '@affine/component/theme';
|
||||||
import '../global.css';
|
import '../app/global.css';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../component" },
|
{ "path": "../../component" },
|
||||||
{ "path": "../../core" },
|
{ "path": "../../core" },
|
||||||
|
{ "path": "../../../common/debug" },
|
||||||
{ "path": "../../electron-api" },
|
{ "path": "../../electron-api" },
|
||||||
{ "path": "../../i18n" },
|
{ "path": "../../i18n" },
|
||||||
{ "path": "../../../common/nbstore" },
|
{ "path": "../../../common/nbstore" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
entry: {
|
entry: {
|
||||||
app: './src/index.tsx',
|
app: './src/app/index.tsx',
|
||||||
shell: './src/shell/index.tsx',
|
shell: './src/shell/index.tsx',
|
||||||
backgroundWorker: './src/background-worker/index.ts',
|
backgroundWorker: './src/background-worker/index.ts',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
|
|||||||
try {
|
try {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const result = await handler(...args);
|
const result = await handler(...args);
|
||||||
logger.info(
|
logger.debug(
|
||||||
'[async-api]',
|
'[async-api]',
|
||||||
`${namespace}.${name}`,
|
`${namespace}.${name}`,
|
||||||
args.filter(
|
args.filter(
|
||||||
@@ -74,7 +74,7 @@ function main() {
|
|||||||
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
|
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
|
||||||
const rendererPort = e.ports[0];
|
const rendererPort = e.ports[0];
|
||||||
setupRendererConnection(rendererPort);
|
setupRendererConnection(rendererPort);
|
||||||
logger.info('[helper] renderer connected');
|
logger.debug('[helper] renderer connected');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ import log from 'electron-log/main';
|
|||||||
export const logger = log.scope('helper');
|
export const logger = log.scope('helper');
|
||||||
|
|
||||||
log.transports.file.level = 'info';
|
log.transports.file.level = 'info';
|
||||||
|
log.transports.console.level = 'info';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { AsyncCall } from 'async-call-rpc';
|
|||||||
|
|
||||||
import type { HelperToMain, MainToHelper } from '../shared/type';
|
import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||||
import { exposed } from './provide';
|
import { exposed } from './provide';
|
||||||
import { encodeToMp3 } from './recording/encode';
|
|
||||||
|
|
||||||
const helperToMainServer: HelperToMain = {
|
const helperToMainServer: HelperToMain = {
|
||||||
getMeta: () => {
|
getMeta: () => {
|
||||||
@@ -11,8 +10,6 @@ const helperToMainServer: HelperToMain = {
|
|||||||
}
|
}
|
||||||
return exposed;
|
return exposed;
|
||||||
},
|
},
|
||||||
// allow main process encode audio samples to mp3 buffer (because it is slow and blocking)
|
|
||||||
encodeToMp3,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
|
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
|
||||||
@@ -33,4 +30,5 @@ export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
|
|||||||
process.parentPort.postMessage(data);
|
process.parentPort.postMessage(data);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
log: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Mp3Encoder } from '@affine/native';
|
|
||||||
|
|
||||||
// encode audio samples to mp3 buffer
|
|
||||||
export function encodeToMp3(
|
|
||||||
samples: Float32Array,
|
|
||||||
opts: {
|
|
||||||
channels?: number;
|
|
||||||
sampleRate?: number;
|
|
||||||
} = {}
|
|
||||||
): Uint8Array {
|
|
||||||
const mp3Encoder = new Mp3Encoder({
|
|
||||||
channels: opts.channels ?? 2,
|
|
||||||
sampleRate: opts.sampleRate ?? 44100,
|
|
||||||
});
|
|
||||||
return mp3Encoder.encode(samples);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
|
import type { MediaStats } from '@toeverything/infra';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { globalStateStorage } from './shared-storage/storage';
|
||||||
|
|
||||||
const cleanupRegistry: (() => void)[] = [];
|
const beforeAppQuitRegistry: (() => void)[] = [];
|
||||||
|
const beforeTabCloseRegistry: ((tabId: string) => void)[] = [];
|
||||||
|
|
||||||
export function beforeAppQuit(fn: () => void) {
|
export function beforeAppQuit(fn: () => void) {
|
||||||
cleanupRegistry.push(fn);
|
beforeAppQuitRegistry.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beforeTabClose(fn: (tabId: string) => void) {
|
||||||
|
beforeTabCloseRegistry.push(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
cleanupRegistry.forEach(fn => {
|
beforeAppQuitRegistry.forEach(fn => {
|
||||||
// some cleanup functions might throw on quit and crash the app
|
// some cleanup functions might throw on quit and crash the app
|
||||||
try {
|
try {
|
||||||
fn();
|
fn();
|
||||||
@@ -18,3 +25,32 @@ app.on('before-quit', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function onTabClose(tabId: string) {
|
||||||
|
beforeTabCloseRegistry.forEach(fn => {
|
||||||
|
try {
|
||||||
|
fn(tabId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('cleanup error on tab close', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('ready', () => {
|
||||||
|
globalStateStorage.set('media:playback-state', null);
|
||||||
|
globalStateStorage.set('media:stats', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAppQuit(() => {
|
||||||
|
globalStateStorage.set('media:playback-state', null);
|
||||||
|
globalStateStorage.set('media:stats', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// set audio play state
|
||||||
|
beforeTabClose(tabId => {
|
||||||
|
const stats = globalStateStorage.get<MediaStats | null>('media:stats');
|
||||||
|
if (stats && stats.tabId === tabId) {
|
||||||
|
globalStateStorage.set('media:playback-state', null);
|
||||||
|
globalStateStorage.set('media:stats', null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class HelperProcessManager {
|
|||||||
unknownMessage: false,
|
unknownMessage: false,
|
||||||
},
|
},
|
||||||
channel: new MessageEventChannel(this.#process),
|
channel: new MessageEventChannel(this.#process),
|
||||||
|
log: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { registerHandlers } from './handlers';
|
|||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { registerProtocol } from './protocol';
|
import { registerProtocol } from './protocol';
|
||||||
import { setupRecording } from './recording';
|
import { setupRecording } from './recording';
|
||||||
import { getTrayState } from './tray';
|
import { setupTrayState } from './tray';
|
||||||
import { registerUpdater } from './updater';
|
import { registerUpdater } from './updater';
|
||||||
import { launch } from './windows-manager/launcher';
|
import { launch } from './windows-manager/launcher';
|
||||||
import { launchStage } from './windows-manager/stage';
|
import { launchStage } from './windows-manager/stage';
|
||||||
@@ -89,7 +89,7 @@ app
|
|||||||
.then(launch)
|
.then(launch)
|
||||||
.then(setupRecording)
|
.then(setupRecording)
|
||||||
.then(createApplicationMenu)
|
.then(createApplicationMenu)
|
||||||
.then(getTrayState)
|
.then(setupTrayState)
|
||||||
.then(registerUpdater)
|
.then(registerUpdater)
|
||||||
.catch(e => console.error('Failed create window:', e));
|
.catch(e => console.error('Failed create window:', e));
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ log.initialize({
|
|||||||
});
|
});
|
||||||
|
|
||||||
log.transports.file.level = 'info';
|
log.transports.file.level = 'info';
|
||||||
|
log.transports.console.level = 'info';
|
||||||
|
|
||||||
export function getLogFilePath() {
|
export function getLogFilePath() {
|
||||||
return log.transports.file.getFile().path;
|
return log.transports.file.getFile().path;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { net, protocol, session } from 'electron';
|
import { app, net, protocol, session } from 'electron';
|
||||||
import cookieParser from 'set-cookie-parser';
|
import cookieParser from 'set-cookie-parser';
|
||||||
|
|
||||||
import { resourcesPath } from '../shared/utils';
|
import { resourcesPath } from '../shared/utils';
|
||||||
@@ -43,8 +43,10 @@ function isNetworkResource(pathname: string) {
|
|||||||
async function handleFileRequest(request: Request) {
|
async function handleFileRequest(request: Request) {
|
||||||
const urlObject = new URL(request.url);
|
const urlObject = new URL(request.url);
|
||||||
|
|
||||||
|
const isAbsolutePath = urlObject.host !== '.';
|
||||||
|
|
||||||
// Redirect to webpack dev server if defined
|
// Redirect to webpack dev server if defined
|
||||||
if (process.env.DEV_SERVER_URL) {
|
if (process.env.DEV_SERVER_URL && !isAbsolutePath) {
|
||||||
const devServerUrl = new URL(
|
const devServerUrl = new URL(
|
||||||
urlObject.pathname,
|
urlObject.pathname,
|
||||||
process.env.DEV_SERVER_URL
|
process.env.DEV_SERVER_URL
|
||||||
@@ -56,20 +58,30 @@ async function handleFileRequest(request: Request) {
|
|||||||
});
|
});
|
||||||
// this will be file types (in the web-static folder)
|
// this will be file types (in the web-static folder)
|
||||||
let filepath = '';
|
let filepath = '';
|
||||||
// if is a file type, load the file in resources
|
|
||||||
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
|
// for relative path, load the file in resources
|
||||||
// Sanitize pathname to prevent path traversal attacks
|
if (!isAbsolutePath) {
|
||||||
const decodedPath = decodeURIComponent(urlObject.pathname);
|
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
|
||||||
const normalizedPath = join(webStaticDir, decodedPath).normalize();
|
// Sanitize pathname to prevent path traversal attacks
|
||||||
if (!normalizedPath.startsWith(webStaticDir)) {
|
const decodedPath = decodeURIComponent(urlObject.pathname);
|
||||||
// Attempted path traversal - reject by using empty path
|
const normalizedPath = join(webStaticDir, decodedPath).normalize();
|
||||||
filepath = join(webStaticDir, '');
|
if (!normalizedPath.startsWith(webStaticDir)) {
|
||||||
|
// Attempted path traversal - reject by using empty path
|
||||||
|
filepath = join(webStaticDir, '');
|
||||||
|
} else {
|
||||||
|
filepath = normalizedPath;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
filepath = normalizedPath;
|
// else, fallback to load the index.html instead
|
||||||
|
filepath = join(webStaticDir, 'index.html');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// else, fallback to load the index.html instead
|
filepath = decodeURIComponent(urlObject.pathname);
|
||||||
filepath = join(webStaticDir, 'index.html');
|
// security check if the filepath is within app.getPath('sessionData')
|
||||||
|
const sessionDataPath = app.getPath('sessionData');
|
||||||
|
if (!filepath.startsWith(sessionDataPath)) {
|
||||||
|
throw new Error('Invalid filepath');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return net.fetch('file://' + filepath, clonedRequest);
|
return net.fetch('file://' + filepath, clonedRequest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { ShareableContent } from '@affine/native';
|
import { ShareableContent } from '@affine/native';
|
||||||
import { nativeImage, Notification } from 'electron';
|
import { app, nativeImage, Notification } from 'electron';
|
||||||
|
import fs from 'fs-extra';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { BehaviorSubject, distinctUntilChanged, groupBy, mergeMap } from 'rxjs';
|
import { BehaviorSubject, distinctUntilChanged, groupBy, mergeMap } from 'rxjs';
|
||||||
|
|
||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { beforeAppQuit } from '../cleanup';
|
import { beforeAppQuit } from '../cleanup';
|
||||||
import { ensureHelperProcess } from '../helper-process';
|
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import type { NamespaceHandlers } from '../type';
|
import type { NamespaceHandlers } from '../type';
|
||||||
import { getMainWindow } from '../windows-manager';
|
import { getMainWindow } from '../windows-manager';
|
||||||
@@ -18,6 +20,11 @@ import type {
|
|||||||
|
|
||||||
const subscribers: Subscriber[] = [];
|
const subscribers: Subscriber[] = [];
|
||||||
|
|
||||||
|
const SAVED_RECORDINGS_DIR = path.join(
|
||||||
|
app.getPath('sessionData'),
|
||||||
|
'recordings'
|
||||||
|
);
|
||||||
|
|
||||||
beforeAppQuit(() => {
|
beforeAppQuit(() => {
|
||||||
subscribers.forEach(subscriber => {
|
subscribers.forEach(subscriber => {
|
||||||
try {
|
try {
|
||||||
@@ -134,7 +141,9 @@ function setupNewRunningAppGroup() {
|
|||||||
recordingStatus$.value?.appGroup?.processGroupId ===
|
recordingStatus$.value?.appGroup?.processGroupId ===
|
||||||
currentGroup.processGroupId
|
currentGroup.processGroupId
|
||||||
) {
|
) {
|
||||||
stopRecording();
|
stopRecording().catch(err => {
|
||||||
|
logger.error('failed to stop recording', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -142,7 +151,13 @@ function setupNewRunningAppGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createRecording(status: RecordingStatus) {
|
function createRecording(status: RecordingStatus) {
|
||||||
const buffers: Float32Array[] = [];
|
const bufferedFilePath = path.join(
|
||||||
|
SAVED_RECORDINGS_DIR,
|
||||||
|
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||||
|
const file = fs.createWriteStream(bufferedFilePath);
|
||||||
|
|
||||||
function tapAudioSamples(err: Error | null, samples: Float32Array) {
|
function tapAudioSamples(err: Error | null, samples: Float32Array) {
|
||||||
const recordingStatus = recordingStatus$.getValue();
|
const recordingStatus = recordingStatus$.getValue();
|
||||||
@@ -157,7 +172,9 @@ function createRecording(status: RecordingStatus) {
|
|||||||
if (err) {
|
if (err) {
|
||||||
logger.error('failed to get audio samples', err);
|
logger.error('failed to get audio samples', err);
|
||||||
} else {
|
} else {
|
||||||
buffers.push(new Float32Array(samples));
|
// Writing raw Float32Array samples directly to file
|
||||||
|
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||||
|
file.write(Buffer.from(samples.buffer));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,44 +187,29 @@ function createRecording(status: RecordingStatus) {
|
|||||||
startTime: status.startTime,
|
startTime: status.startTime,
|
||||||
app: status.app,
|
app: status.app,
|
||||||
appGroup: status.appGroup,
|
appGroup: status.appGroup,
|
||||||
buffers,
|
file,
|
||||||
stream,
|
stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
return recording;
|
return recording;
|
||||||
}
|
}
|
||||||
|
|
||||||
function concatBuffers(buffers: Float32Array[]): Float32Array {
|
export async function getRecording(id: number) {
|
||||||
const totalSamples = buffers.reduce((acc, buf) => acc + buf.length, 0);
|
|
||||||
const buffer = new Float32Array(totalSamples);
|
|
||||||
let offset = 0;
|
|
||||||
buffers.forEach(buf => {
|
|
||||||
buffer.set(buf, offset);
|
|
||||||
offset += buf.length;
|
|
||||||
});
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveRecording(id: number) {
|
|
||||||
const recording = recordings.get(id);
|
const recording = recordings.get(id);
|
||||||
if (!recording) {
|
if (!recording) {
|
||||||
logger.error(`Recording ${id} not found`);
|
logger.error(`Recording ${id} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { buffers } = recording;
|
const rawFilePath = String(recording.file.path);
|
||||||
const helperProcessManager = await ensureHelperProcess();
|
return {
|
||||||
const buffer = concatBuffers(buffers);
|
id,
|
||||||
const mp3Buffer = await helperProcessManager.rpc?.encodeToMp3(buffer, {
|
appGroup: recording.appGroup,
|
||||||
channels: recording.stream.channels,
|
app: recording.app,
|
||||||
|
startTime: recording.startTime,
|
||||||
|
filepath: rawFilePath,
|
||||||
sampleRate: recording.stream.sampleRate,
|
sampleRate: recording.stream.sampleRate,
|
||||||
});
|
numberOfChannels: recording.stream.channels,
|
||||||
|
};
|
||||||
if (!mp3Buffer) {
|
|
||||||
logger.error('failed to encode audio samples to mp3');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
recordings.delete(recording.id);
|
|
||||||
return mp3Buffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupRecordingListeners() {
|
function setupRecordingListeners() {
|
||||||
@@ -386,9 +388,15 @@ export function resumeRecording() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopRecording() {
|
export async function stopRecording() {
|
||||||
const recordingStatus = recordingStatus$.value;
|
const recordingStatus = recordingStatus$.value;
|
||||||
if (!recordingStatus) {
|
if (!recordingStatus) {
|
||||||
|
logger.error('No recording status to stop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recording = recordings.get(recordingStatus?.id);
|
||||||
|
if (!recording) {
|
||||||
|
logger.error(`Recording ${recordingStatus?.id} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +406,15 @@ export function stopRecording() {
|
|||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { file } = recording;
|
||||||
|
file.end();
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
file.on('finish', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// bring up the window
|
// bring up the window
|
||||||
getMainWindow()
|
getMainWindow()
|
||||||
.then(mainWindow => {
|
.then(mainWindow => {
|
||||||
@@ -411,8 +428,17 @@ export function stopRecording() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const recordingHandlers = {
|
export const recordingHandlers = {
|
||||||
saveRecording: async (_, id: number) => {
|
getRecording: async (_, id: number) => {
|
||||||
return saveRecording(id);
|
return getRecording(id);
|
||||||
|
},
|
||||||
|
deleteCachedRecording: async (_, id: number) => {
|
||||||
|
const recording = recordings.get(id);
|
||||||
|
if (recording) {
|
||||||
|
recording.stream.stop();
|
||||||
|
recordings.delete(id);
|
||||||
|
await fs.unlink(recording.file.path);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
} satisfies NamespaceHandlers;
|
} satisfies NamespaceHandlers;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { WriteStream } from 'node:fs';
|
||||||
|
|
||||||
import type { AudioTapStream, TappableApplication } from '@affine/native';
|
import type { AudioTapStream, TappableApplication } from '@affine/native';
|
||||||
|
|
||||||
export interface TappableAppInfo {
|
export interface TappableAppInfo {
|
||||||
@@ -23,8 +25,8 @@ export interface Recording {
|
|||||||
// the app may not be available if the user choose to record system audio
|
// the app may not be available if the user choose to record system audio
|
||||||
app?: TappableAppInfo;
|
app?: TappableAppInfo;
|
||||||
appGroup?: AppGroupInfo;
|
appGroup?: AppGroupInfo;
|
||||||
// the raw audio buffers that are already accumulated
|
// the buffered file that is being recorded streamed to
|
||||||
buffers: Float32Array[];
|
file: WriteStream;
|
||||||
stream: AudioTapStream;
|
stream: AudioTapStream;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,9 @@ class TrayState {
|
|||||||
label: 'Stop',
|
label: 'Stop',
|
||||||
click: () => {
|
click: () => {
|
||||||
logger.info('User action: Stop Recording');
|
logger.info('User action: Stop Recording');
|
||||||
stopRecording();
|
stopRecording().catch(err => {
|
||||||
|
logger.error('Failed to stop recording:', err);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -290,7 +292,7 @@ class TrayState {
|
|||||||
|
|
||||||
let _trayState: TrayState | undefined;
|
let _trayState: TrayState | undefined;
|
||||||
|
|
||||||
export const getTrayState = () => {
|
export const setupTrayState = () => {
|
||||||
if (!_trayState) {
|
if (!_trayState) {
|
||||||
_trayState = new TrayState();
|
_trayState = new TrayState();
|
||||||
_trayState.init();
|
_trayState.init();
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export const uiEvents = {
|
|||||||
},
|
},
|
||||||
onTabsStatusChange,
|
onTabsStatusChange,
|
||||||
onActiveTabChanged,
|
onActiveTabChanged,
|
||||||
|
onTabGoToRequest: (fn: (opts: { tabId: string; to: string }) => void) => {
|
||||||
|
const sub = uiSubjects.tabGoToRequest$.subscribe(fn);
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
},
|
||||||
onTabShellViewActiveChange,
|
onTabShellViewActiveChange,
|
||||||
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
|
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
|
||||||
const sub = uiSubjects.authenticationRequest$.subscribe(fn);
|
const sub = uiSubjects.authenticationRequest$.subscribe(fn);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
activateView,
|
activateView,
|
||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
ensureTabLoaded,
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
getOnboardingWindow,
|
getOnboardingWindow,
|
||||||
getTabsStatus,
|
getTabsStatus,
|
||||||
@@ -187,6 +188,12 @@ export const uiHandlers = {
|
|||||||
showTab: async (_, ...args: Parameters<typeof showTab>) => {
|
showTab: async (_, ...args: Parameters<typeof showTab>) => {
|
||||||
await showTab(...args);
|
await showTab(...args);
|
||||||
},
|
},
|
||||||
|
tabGoTo: async (_, tabId: string, to: string) => {
|
||||||
|
uiSubjects.tabGoToRequest$.next({ tabId, to });
|
||||||
|
},
|
||||||
|
ensureTabLoaded: async (_, ...args: Parameters<typeof ensureTabLoaded>) => {
|
||||||
|
await ensureTabLoaded(...args);
|
||||||
|
},
|
||||||
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
|
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
|
||||||
await closeTab(...args);
|
await closeTab(...args);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const uiSubjects = {
|
|||||||
onMaximized$: new Subject<boolean>(),
|
onMaximized$: new Subject<boolean>(),
|
||||||
onFullScreen$: new Subject<boolean>(),
|
onFullScreen$: new Subject<boolean>(),
|
||||||
onToggleRightSidebar$: new Subject<string>(),
|
onToggleRightSidebar$: new Subject<string>(),
|
||||||
|
tabGoToRequest$: new Subject<{ tabId: string; to: string }>(),
|
||||||
authenticationRequest$: new Subject<AuthenticationRequest>(),
|
authenticationRequest$: new Subject<AuthenticationRequest>(),
|
||||||
// via menu -> close view (CMD+W)
|
// via menu -> close view (CMD+W)
|
||||||
onCloseView$: new Subject<void>(),
|
onCloseView$: new Subject<void>(),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { beforeAppQuit } from '../cleanup';
|
import { beforeAppQuit, onTabClose } from '../cleanup';
|
||||||
import { mainWindowOrigin, shellViewUrl } from '../constants';
|
import { mainWindowOrigin, shellViewUrl } from '../constants';
|
||||||
import { ensureHelperProcess } from '../helper-process';
|
import { ensureHelperProcess } from '../helper-process';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
@@ -400,6 +400,8 @@ export class WebContentViewsManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 500); // delay a bit to get rid of the flicker
|
}, 500); // delay a bit to get rid of the flicker
|
||||||
|
|
||||||
|
onTabClose(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
undoCloseTab = async () => {
|
undoCloseTab = async () => {
|
||||||
@@ -1052,6 +1054,13 @@ export const loadUrlInActiveTab = async (_url: string) => {
|
|||||||
// todo: implement
|
// todo: implement
|
||||||
throw new Error('loadUrlInActiveTab not implemented');
|
throw new Error('loadUrlInActiveTab not implemented');
|
||||||
};
|
};
|
||||||
|
export const ensureTabLoaded = async (tabId: string) => {
|
||||||
|
const tab = WebContentViewsManager.instance.tabViewsMap.get(tabId);
|
||||||
|
if (tab) {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
return WebContentViewsManager.instance.loadTab(tabId);
|
||||||
|
};
|
||||||
export const showTab = WebContentViewsManager.instance.showTab;
|
export const showTab = WebContentViewsManager.instance.showTab;
|
||||||
export const closeTab = WebContentViewsManager.instance.closeTab;
|
export const closeTab = WebContentViewsManager.instance.closeTab;
|
||||||
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
|
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ export interface HelperToRenderer {
|
|||||||
// helper <-> main
|
// helper <-> main
|
||||||
export interface HelperToMain {
|
export interface HelperToMain {
|
||||||
getMeta: () => ExposedMeta;
|
getMeta: () => ExposedMeta;
|
||||||
encodeToMp3: (
|
|
||||||
samples: Float32Array,
|
|
||||||
opts?: {
|
|
||||||
channels?: number;
|
|
||||||
sampleRate?: number;
|
|
||||||
}
|
|
||||||
) => Uint8Array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MainToHelper = Pick<
|
export type MainToHelper = Pick<
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: BSD-3-Clause
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { PropertyDeclaration } from 'lit';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
const DEV_MODE = process.env.NODE_ENV !== 'production';
|
const DEV_MODE = process.env.NODE_ENV !== 'production';
|
||||||
@@ -164,6 +165,7 @@ const setProperty = <E extends Element>(
|
|||||||
name: string,
|
name: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
old: unknown,
|
old: unknown,
|
||||||
|
elementProperties?: Map<string, PropertyDeclaration>,
|
||||||
events?: EventNames
|
events?: EventNames
|
||||||
) => {
|
) => {
|
||||||
const event = events?.[name];
|
const event = events?.[name];
|
||||||
@@ -175,6 +177,15 @@ const setProperty = <E extends Element>(
|
|||||||
// But don't dirty check properties; elements are assumed to do this.
|
// But don't dirty check properties; elements are assumed to do this.
|
||||||
node[name as keyof E] = value as E[keyof E];
|
node[name as keyof E] = value as E[keyof E];
|
||||||
|
|
||||||
|
if (elementProperties && elementProperties.has(name)) {
|
||||||
|
const property = elementProperties.get(name);
|
||||||
|
if (property?.attribute) {
|
||||||
|
const attributeName =
|
||||||
|
property.attribute === true ? name : property.attribute;
|
||||||
|
node.setAttribute(attributeName, value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This block is to replicate React's behavior for attributes of native
|
// This block is to replicate React's behavior for attributes of native
|
||||||
// elements where `undefined` or `null` values result in attributes being
|
// elements where `undefined` or `null` values result in attributes being
|
||||||
// removed.
|
// removed.
|
||||||
@@ -302,6 +313,12 @@ export const createComponent = <
|
|||||||
props[prop],
|
props[prop],
|
||||||
// @ts-expect-error: prop is a key of props
|
// @ts-expect-error: prop is a key of props
|
||||||
prevPropsRef.current ? prevPropsRef.current[prop] : undefined,
|
prevPropsRef.current ? prevPropsRef.current[prop] : undefined,
|
||||||
|
'elementProperties' in elementClass
|
||||||
|
? (elementClass.elementProperties as Map<
|
||||||
|
string,
|
||||||
|
PropertyDeclaration
|
||||||
|
>)
|
||||||
|
: undefined,
|
||||||
events
|
events
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
|
"@lit/context": "^1.1.4",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
|
|||||||
+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 { AIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-block';
|
||||||
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
|
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
|
||||||
|
import { LitTranscriptionBlock } from './blocks/ai-chat-block/ai-transcription-block';
|
||||||
import {
|
import {
|
||||||
AIChatMessage,
|
AIChatMessage,
|
||||||
AIChatMessages,
|
AIChatMessages,
|
||||||
@@ -151,5 +152,7 @@ export function registerAIEffects() {
|
|||||||
EdgelessCopilotToolbarEntry
|
EdgelessCopilotToolbarEntry
|
||||||
);
|
);
|
||||||
|
|
||||||
|
customElements.define('transcription-block', LitTranscriptionBlock);
|
||||||
|
|
||||||
SpecProvider._.extendSpec('store', [AIChatBlockSchemaExtension]);
|
SpecProvider._.extendSpec('store', [AIChatBlockSchemaExtension]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { SpecBuilder } from '@blocksuite/affine/shared/utils';
|
|||||||
import type { FrameworkProvider } from '@toeverything/infra';
|
import type { FrameworkProvider } from '@toeverything/infra';
|
||||||
|
|
||||||
import { AIChatBlockSpec } from '../blocks';
|
import { AIChatBlockSpec } from '../blocks';
|
||||||
|
import { AITranscriptionBlockSpec } from '../blocks/ai-chat-block/ai-transcription-block';
|
||||||
import { AICodeBlockSpec } from './ai-code';
|
import { AICodeBlockSpec } from './ai-code';
|
||||||
import { createAIEdgelessRootBlockSpec } from './ai-edgeless-root';
|
import { createAIEdgelessRootBlockSpec } from './ai-edgeless-root';
|
||||||
import { AIImageBlockSpec } from './ai-image';
|
import { AIImageBlockSpec } from './ai-image';
|
||||||
@@ -39,4 +40,5 @@ export function enableAIExtension(
|
|||||||
}
|
}
|
||||||
|
|
||||||
specBuilder.extend(AIChatBlockSpec);
|
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 { Suspense } from 'react';
|
||||||
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
|
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './error.css';
|
||||||
import { download } from './utils';
|
import { download } from './utils';
|
||||||
|
|
||||||
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
|
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
|
||||||
@@ -67,7 +67,7 @@ interface ErrorBaseProps {
|
|||||||
buttons?: ReactElement[];
|
buttons?: ReactElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorBase = ({
|
const ErrorBase = ({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
icon = <FileIcon />,
|
icon = <FileIcon />,
|
||||||
@@ -88,7 +88,7 @@ interface ErrorProps {
|
|||||||
ext: string;
|
ext: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Error = ({ model, ext }: ErrorProps) => {
|
export const AttachmentFallback = ({ model, ext }: ErrorProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const Icon = FILE_ICONS[model.props.type] ?? FileIcon;
|
const Icon = FILE_ICONS[model.props.type] ?? FileIcon;
|
||||||
const title = t['com.affine.attachment.preview.error.title']();
|
const title = t['com.affine.attachment.preview.error.title']();
|
||||||
+7
-7
@@ -1,11 +1,11 @@
|
|||||||
import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
|
import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
|
||||||
|
|
||||||
import { AttachmentPreviewErrorBoundary, Error } from './error';
|
import { AttachmentFallback, AttachmentPreviewErrorBoundary } from './error';
|
||||||
import { PDFViewer } from './pdf-viewer';
|
import { PDFViewer } from './pdf/pdf-viewer';
|
||||||
import * as styles from './styles.css';
|
import type { AttachmentViewerBaseProps, AttachmentViewerProps } from './types';
|
||||||
import { Titlebar } from './titlebar';
|
|
||||||
import type { AttachmentViewerProps, PDFViewerProps } from './types';
|
|
||||||
import { buildAttachmentProps } from './utils';
|
import { buildAttachmentProps } from './utils';
|
||||||
|
import { Titlebar } from './viewer';
|
||||||
|
import * as styles from './viewer.css';
|
||||||
|
|
||||||
// In Peek view
|
// In Peek view
|
||||||
export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
|
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') ? (
|
return props.model.props.type.endsWith('pdf') ? (
|
||||||
<AttachmentPreviewErrorBoundary>
|
<AttachmentPreviewErrorBoundary>
|
||||||
<PDFViewer {...props} />
|
<PDFViewer {...props} />
|
||||||
</AttachmentPreviewErrorBoundary>
|
</AttachmentPreviewErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
<Error {...props} />
|
<AttachmentFallback {...props} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+12
-22
@@ -28,9 +28,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import type { AttachmentViewerProps } from '../types';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import * as embeddedStyles from './styles.embedded.css';
|
|
||||||
import type { PDFViewerProps } from './types';
|
|
||||||
|
|
||||||
function defaultMeta() {
|
function defaultMeta() {
|
||||||
return {
|
return {
|
||||||
@@ -40,7 +39,7 @@ function defaultMeta() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
|
||||||
const scale = window.devicePixelRatio;
|
const scale = window.devicePixelRatio;
|
||||||
const peekView = useService(PeekViewService).peekView;
|
const peekView = useService(PeekViewService).peekView;
|
||||||
const pdfService = useService(PDFService);
|
const pdfService = useService(PDFService);
|
||||||
@@ -194,8 +193,8 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
|
<div ref={viewerRef} className={styles.pdfContainer}>
|
||||||
<main className={embeddedStyles.pdfViewer}>
|
<main className={styles.pdfViewer}>
|
||||||
<div
|
<div
|
||||||
className={styles.pdfPage}
|
className={styles.pdfPage}
|
||||||
style={{
|
style={{
|
||||||
@@ -216,11 +215,11 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={embeddedStyles.pdfControls}>
|
<div className={styles.pdfControls}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size={16}
|
size={16}
|
||||||
icon={<ArrowUpSmallIcon />}
|
icon={<ArrowUpSmallIcon />}
|
||||||
className={embeddedStyles.pdfControlButton}
|
className={styles.pdfControlButton}
|
||||||
onDoubleClick={stopPropagation}
|
onDoubleClick={stopPropagation}
|
||||||
aria-label="Prev"
|
aria-label="Prev"
|
||||||
{...navigator.prev}
|
{...navigator.prev}
|
||||||
@@ -228,7 +227,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size={16}
|
size={16}
|
||||||
icon={<ArrowDownSmallIcon />}
|
icon={<ArrowDownSmallIcon />}
|
||||||
className={embeddedStyles.pdfControlButton}
|
className={styles.pdfControlButton}
|
||||||
onDoubleClick={stopPropagation}
|
onDoubleClick={stopPropagation}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
{...navigator.next}
|
{...navigator.next}
|
||||||
@@ -236,27 +235,18 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size={16}
|
size={16}
|
||||||
icon={<CenterPeekIcon />}
|
icon={<CenterPeekIcon />}
|
||||||
className={embeddedStyles.pdfControlButton}
|
className={styles.pdfControlButton}
|
||||||
onDoubleClick={stopPropagation}
|
onDoubleClick={stopPropagation}
|
||||||
{...navigator.peek}
|
{...navigator.peek}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className={embeddedStyles.pdfFooter}>
|
<footer className={styles.pdfFooter}>
|
||||||
<div
|
<div className={clsx([styles.pdfFooterItem, { truncate: true }])}>
|
||||||
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
|
|
||||||
>
|
|
||||||
<AttachmentIcon />
|
<AttachmentIcon />
|
||||||
<span className={clsx([embeddedStyles.pdfTitle, 'pdf-name'])}>
|
<span className={clsx([styles.pdfTitle, 'pdf-name'])}>{name}</span>
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={clsx([styles.pdfFooterItem, styles.pdfPageCount])}>
|
||||||
className={clsx([
|
|
||||||
embeddedStyles.pdfFooterItem,
|
|
||||||
embeddedStyles.pdfPageCount,
|
|
||||||
])}
|
|
||||||
>
|
|
||||||
<span className="page-cursor">
|
<span className="page-cursor">
|
||||||
{meta.pageCount > 0 ? cursor + 1 : '-'}
|
{meta.pageCount > 0 ? cursor + 1 : '-'}
|
||||||
</span>
|
</span>
|
||||||
+61
-6
@@ -1,21 +1,21 @@
|
|||||||
import { IconButton, observeResize } from '@affine/component';
|
import { IconButton, observeResize } from '@affine/component';
|
||||||
import type {
|
import type { PDF, PDFRendererState } from '@affine/core/modules/pdf';
|
||||||
PDF,
|
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||||
PDFRendererState,
|
|
||||||
PDFStatus,
|
|
||||||
} from '@affine/core/modules/pdf';
|
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
List,
|
List,
|
||||||
ListPadding,
|
ListPadding,
|
||||||
ListWithSmallGap,
|
ListWithSmallGap,
|
||||||
|
LoadingSvg,
|
||||||
PDFPageRenderer,
|
PDFPageRenderer,
|
||||||
type PDFVirtuosoContext,
|
type PDFVirtuosoContext,
|
||||||
type PDFVirtuosoProps,
|
type PDFVirtuosoProps,
|
||||||
Scroller,
|
Scroller,
|
||||||
ScrollSeekPlaceholder,
|
ScrollSeekPlaceholder,
|
||||||
} from '@affine/core/modules/pdf/views';
|
} from '@affine/core/modules/pdf/views';
|
||||||
|
import track from '@affine/track';
|
||||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -24,11 +24,21 @@ import {
|
|||||||
type VirtuosoHandle,
|
type VirtuosoHandle,
|
||||||
} from 'react-virtuoso';
|
} from 'react-virtuoso';
|
||||||
|
|
||||||
|
import type { AttachmentViewerProps } from '../types';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { calculatePageNum, fitToPage } from './utils';
|
import { fitToPage } from './utils';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 94;
|
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 {
|
export interface PDFViewerInnerProps {
|
||||||
pdf: PDF;
|
pdf: PDF;
|
||||||
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
||||||
@@ -234,3 +244,48 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
</div>
|
</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 { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const viewerContainer = style({
|
export { viewer } from '../viewer.css';
|
||||||
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({
|
export const virtuoso = style({
|
||||||
width: '100%',
|
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({
|
export const pdfIndicator = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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';
|
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||||
|
|
||||||
|
export type AttachmentType = 'pdf' | 'image' | 'audio' | 'video' | 'unknown';
|
||||||
|
|
||||||
export type AttachmentViewerProps = {
|
export type AttachmentViewerProps = {
|
||||||
model: AttachmentBlockModel;
|
model: AttachmentBlockModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PDFViewerProps = {
|
export type AttachmentViewerBaseProps = {
|
||||||
model: AttachmentBlockModel;
|
model: AttachmentBlockModel;
|
||||||
name: string;
|
name: string;
|
||||||
ext: 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 clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
|
||||||
import { download } from './utils';
|
import { download } from './utils';
|
||||||
|
import * as styles from './viewer.css';
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
/*
|
/*
|
||||||
@@ -50,7 +50,10 @@ import {
|
|||||||
type DefaultOpenProperty,
|
type DefaultOpenProperty,
|
||||||
DocPropertiesTable,
|
DocPropertiesTable,
|
||||||
} from '../../components/doc-properties';
|
} 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 { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
|
||||||
import { patchDocModeService } from '../extensions/doc-mode-service';
|
import { patchDocModeService } from '../extensions/doc-mode-service';
|
||||||
import { patchDocUrlExtensions } from '../extensions/doc-url';
|
import { patchDocUrlExtensions } from '../extensions/doc-url';
|
||||||
@@ -148,6 +151,14 @@ const usePatchSpecs = (mode: DocMode) => {
|
|||||||
featureFlagService.flags.enable_turbo_renderer.$
|
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 patchedSpecs = useMemo(() => {
|
||||||
const builder = enableEditorExtension(framework, mode, enableAI);
|
const builder = enableEditorExtension(framework, mode, enableAI);
|
||||||
|
|
||||||
@@ -176,8 +187,11 @@ const usePatchSpecs = (mode: DocMode) => {
|
|||||||
].flat()
|
].flat()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (featureFlagService.flags.enable_pdf_embed_preview.value) {
|
if (enablePDFEmbedPreview) {
|
||||||
builder.extend([patchForAttachmentEmbedViews(reactToLit)]);
|
builder.extend([patchForPDFEmbedView(reactToLit)]);
|
||||||
|
}
|
||||||
|
if (enableAudioBlock) {
|
||||||
|
builder.extend([patchForAudioEmbedView(reactToLit)]);
|
||||||
}
|
}
|
||||||
if (BUILD_CONFIG.isMobileEdition) {
|
if (BUILD_CONFIG.isMobileEdition) {
|
||||||
enableMobileExtension(builder, framework);
|
enableMobileExtension(builder, framework);
|
||||||
@@ -202,7 +216,8 @@ const usePatchSpecs = (mode: DocMode) => {
|
|||||||
memberSearchService,
|
memberSearchService,
|
||||||
publicUserService,
|
publicUserService,
|
||||||
enableTurboRenderer,
|
enableTurboRenderer,
|
||||||
featureFlagService.flags.enable_pdf_embed_preview.value,
|
enablePDFEmbedPreview,
|
||||||
|
enableAudioBlock,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ElementOrFactory } from '@affine/component';
|
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 { AttachmentEmbedConfigIdentifier } from '@blocksuite/affine/blocks/attachment';
|
||||||
import { Bound } from '@blocksuite/affine/global/gfx';
|
import { Bound } from '@blocksuite/affine/global/gfx';
|
||||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||||
import type { TemplateResult } from 'lit';
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
export function patchForAttachmentEmbedViews(
|
export function patchForPDFEmbedView(
|
||||||
reactToLit: (
|
reactToLit: (
|
||||||
element: ElementOrFactory,
|
element: ElementOrFactory,
|
||||||
rerendering?: boolean
|
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 type { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { AIChatBlockSpec } from '../../ai/blocks';
|
import { AIChatBlockSpec } from '../../ai/blocks';
|
||||||
|
import { AITranscriptionBlockSpec } from '../../ai/blocks/ai-chat-block/ai-transcription-block';
|
||||||
import { buildDocDisplayMetaExtension } from '../display-meta';
|
import { buildDocDisplayMetaExtension } from '../display-meta';
|
||||||
import { getFontConfigExtension } from '../font-config';
|
import { getFontConfigExtension } from '../font-config';
|
||||||
import { patchPeekViewService } from '../peek-view-service';
|
import { patchPeekViewService } from '../peek-view-service';
|
||||||
@@ -104,6 +105,7 @@ export function enablePreviewExtension(framework: FrameworkProvider): void {
|
|||||||
|
|
||||||
_previewExtensions = [
|
_previewExtensions = [
|
||||||
...AIChatBlockSpec,
|
...AIChatBlockSpec,
|
||||||
|
...AITranscriptionBlockSpec,
|
||||||
fontConfig,
|
fontConfig,
|
||||||
getThemeExtension(framework),
|
getThemeExtension(framework),
|
||||||
getPagePreviewThemeExtension(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'),
|
color: cssVar('pureWhite'),
|
||||||
background: cssVar('primaryColor'),
|
background: cssVar('primaryColor'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const bottomContainer = style({
|
||||||
|
gap: 8,
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { memo, useCallback } from 'react';
|
|||||||
import { WorkbenchService } from '../../modules/workbench';
|
import { WorkbenchService } from '../../modules/workbench';
|
||||||
import { WorkspaceNavigator } from '../workspace-selector';
|
import { WorkspaceNavigator } from '../workspace-selector';
|
||||||
import {
|
import {
|
||||||
|
bottomContainer,
|
||||||
quickSearch,
|
quickSearch,
|
||||||
quickSearchAndNewPage,
|
quickSearchAndNewPage,
|
||||||
workspaceAndUserWrapper,
|
workspaceAndUserWrapper,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
} from './index.css';
|
} from './index.css';
|
||||||
import { AppSidebarJournalButton } from './journal-button';
|
import { AppSidebarJournalButton } from './journal-button';
|
||||||
import { NotificationButton } from './notification-button';
|
import { NotificationButton } from './notification-button';
|
||||||
|
import { SidebarAudioPlayer } from './sidebar-audio-player';
|
||||||
import { TemplateDocEntrance } from './template-doc-entrance';
|
import { TemplateDocEntrance } from './template-doc-entrance';
|
||||||
import { TrashButton } from './trash-button';
|
import { TrashButton } from './trash-button';
|
||||||
import { UpdaterButton } from './updater-button';
|
import { UpdaterButton } from './updater-button';
|
||||||
@@ -204,7 +206,8 @@ export const RootAppSidebar = memo((): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</SidebarScrollableContainer>
|
</SidebarScrollableContainer>
|
||||||
<SidebarContainer>
|
<SidebarContainer className={bottomContainer}>
|
||||||
|
<SidebarAudioPlayer />
|
||||||
{BUILD_CONFIG.isElectron ? <UpdaterButton /> : <AppDownloadButton />}
|
{BUILD_CONFIG.isElectron ? <UpdaterButton /> : <AppDownloadButton />}
|
||||||
</SidebarContainer>
|
</SidebarContainer>
|
||||||
</AppSidebar>
|
</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 { Skeleton } from '@affine/component';
|
||||||
|
import { AttachmentViewer } from '@affine/core/blocksuite/attachment-viewer';
|
||||||
import { type Doc, DocsService } from '@affine/core/modules/doc';
|
import { type Doc, DocsService } from '@affine/core/modules/doc';
|
||||||
import { type AttachmentBlockModel } from '@blocksuite/affine/model';
|
import { type AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||||
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
|
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
|
||||||
import { type ReactElement, useLayoutEffect, useState } from 'react';
|
import { type ReactElement, useLayoutEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AttachmentViewerView } from '../../../../components/attachment-viewer';
|
|
||||||
import { ViewIcon, ViewTitle } from '../../../../modules/workbench';
|
import { ViewIcon, ViewTitle } from '../../../../modules/workbench';
|
||||||
import { PageNotFound } from '../../404';
|
import { PageNotFound } from '../../404';
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
@@ -71,7 +71,7 @@ export const AttachmentPage = ({
|
|||||||
<ViewIcon
|
<ViewIcon
|
||||||
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
|
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
|
||||||
/>
|
/>
|
||||||
<AttachmentViewerView model={model} />
|
<AttachmentViewer model={model} />
|
||||||
</FrameworkScope>
|
</FrameworkScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,28 @@ import { type PropsWithChildren } from 'react';
|
|||||||
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
export function SidebarContainer({ children }: PropsWithChildren) {
|
interface SidebarContainerProps extends PropsWithChildren {
|
||||||
return <div className={clsx([styles.baseContainer])}>{children}</div>;
|
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();
|
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
<ScrollArea.Root
|
||||||
|
className={clsx([styles.scrollableContainerRoot, className])}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-has-scroll-top={hasScrollTop}
|
data-has-scroll-top={hasScrollTop}
|
||||||
className={styles.scrollTopBorder}
|
className={styles.scrollTopBorder}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { notify } from '@affine/component';
|
import { notify } from '@affine/component';
|
||||||
import { I18n } from '@affine/i18n';
|
import { I18n } from '@affine/i18n';
|
||||||
import { OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
|
import type { To } from 'history';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
import { AuthService, DefaultServerService, ServersService } from '../../cloud';
|
import { AuthService, DefaultServerService, ServersService } from '../../cloud';
|
||||||
@@ -32,6 +33,25 @@ export class DesktopApiService extends Service {
|
|||||||
return this.api.sharedStorage;
|
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() {
|
private setupStartListener() {
|
||||||
this.setupCommonUIEvents();
|
this.setupCommonUIEvents();
|
||||||
this.setupAuthRequestEvent();
|
this.setupAuthRequestEvent();
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: !isMobile,
|
configurable: !isMobile,
|
||||||
defaultState: false,
|
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: {
|
enable_editor_rtl: {
|
||||||
category: 'affine',
|
category: 'affine',
|
||||||
displayName:
|
displayName:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { configureImportTemplateModule } from './import-template';
|
|||||||
import { configureIntegrationModule } from './integration';
|
import { configureIntegrationModule } from './integration';
|
||||||
import { configureJournalModule } from './journal';
|
import { configureJournalModule } from './journal';
|
||||||
import { configureLifecycleModule } from './lifecycle';
|
import { configureLifecycleModule } from './lifecycle';
|
||||||
|
import { configureMediaModule } from './media';
|
||||||
import { configureNavigationModule } from './navigation';
|
import { configureNavigationModule } from './navigation';
|
||||||
import { configureNotificationModule } from './notification';
|
import { configureNotificationModule } from './notification';
|
||||||
import { configureOpenInApp } from './open-in-app';
|
import { configureOpenInApp } from './open-in-app';
|
||||||
@@ -103,6 +104,7 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureAIButtonModule(framework);
|
configureAIButtonModule(framework);
|
||||||
configureTemplateDocModule(framework);
|
configureTemplateDocModule(framework);
|
||||||
configureBlobManagementModule(framework);
|
configureBlobManagementModule(framework);
|
||||||
|
configureMediaModule(framework);
|
||||||
configureImportClipperModule(framework);
|
configureImportClipperModule(framework);
|
||||||
configureNotificationModule(framework);
|
configureNotificationModule(framework);
|
||||||
configureIntegrationModule(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user