feat(editor): audio block (#10947)

AudioMedia entity for loading & controlling a single audio media
AudioMediaManagerService: Global audio state synchronization across tabs
AudioAttachmentService + AudioAttachmentBlock for manipulating AttachmentBlock in affine - e.g., filling transcription (using mock endpoint for now)
Added AudioBlock + AudioPlayer for rendering audio block in affine (new transcription block whose renderer is provided in affine)

fix AF-2292
fix AF-2337
This commit is contained in:
pengx17
2025-03-20 12:46:14 +00:00
parent 8a5393ea50
commit fad49bb070
120 changed files with 5407 additions and 950 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
@@ -24,7 +25,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.28.0",
"uuid": "^11.0.3"
"uuid": "^11.0.3",
"webm-muxer": "^5.0.3"
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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);
});
}

View File

@@ -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();
},
};
},
});
}

View File

@@ -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,
};
}

View File

@@ -1,3 +1,3 @@
import '@affine/core/bootstrap/electron';
import '@affine/component/theme';
import '../global.css';
import '../app/global.css';

View File

@@ -10,6 +10,7 @@
"references": [
{ "path": "../../component" },
{ "path": "../../core" },
{ "path": "../../../common/debug" },
{ "path": "../../electron-api" },
{ "path": "../../i18n" },
{ "path": "../../../common/nbstore" },

View File

@@ -1,6 +1,6 @@
export const config = {
entry: {
app: './src/index.tsx',
app: './src/app/index.tsx',
shell: './src/shell/index.tsx',
backgroundWorker: './src/background-worker/index.ts',
},

View File

@@ -12,7 +12,7 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
try {
const start = performance.now();
const result = await handler(...args);
logger.info(
logger.debug(
'[async-api]',
`${namespace}.${name}`,
args.filter(
@@ -74,7 +74,7 @@ function main() {
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
const rendererPort = e.ports[0];
setupRendererConnection(rendererPort);
logger.info('[helper] renderer connected');
logger.debug('[helper] renderer connected');
}
});
}

View File

@@ -3,3 +3,4 @@ import log from 'electron-log/main';
export const logger = log.scope('helper');
log.transports.file.level = 'info';
log.transports.console.level = 'info';

View File

@@ -2,7 +2,6 @@ import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide';
import { encodeToMp3 } from './recording/encode';
const helperToMainServer: HelperToMain = {
getMeta: () => {
@@ -11,8 +10,6 @@ const helperToMainServer: HelperToMain = {
}
return exposed;
},
// allow main process encode audio samples to mp3 buffer (because it is slow and blocking)
encodeToMp3,
};
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
@@ -33,4 +30,5 @@ export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
process.parentPort.postMessage(data);
},
},
log: false,
});

View File

@@ -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);
}

View File

@@ -1,15 +1,22 @@
import type { MediaStats } from '@toeverything/infra';
import { app } from 'electron';
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) {
cleanupRegistry.push(fn);
beforeAppQuitRegistry.push(fn);
}
export function beforeTabClose(fn: (tabId: string) => void) {
beforeTabCloseRegistry.push(fn);
}
app.on('before-quit', () => {
cleanupRegistry.forEach(fn => {
beforeAppQuitRegistry.forEach(fn => {
// some cleanup functions might throw on quit and crash the app
try {
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);
}
});

View File

@@ -115,6 +115,7 @@ class HelperProcessManager {
unknownMessage: false,
},
channel: new MessageEventChannel(this.#process),
log: false,
});
}
}

View File

@@ -15,7 +15,7 @@ import { registerHandlers } from './handlers';
import { logger } from './logger';
import { registerProtocol } from './protocol';
import { setupRecording } from './recording';
import { getTrayState } from './tray';
import { setupTrayState } from './tray';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage';
@@ -89,7 +89,7 @@ app
.then(launch)
.then(setupRecording)
.then(createApplicationMenu)
.then(getTrayState)
.then(setupTrayState)
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));

View File

@@ -7,6 +7,7 @@ log.initialize({
});
log.transports.file.level = 'info';
log.transports.console.level = 'info';
export function getLogFilePath() {
return log.transports.file.getFile().path;

View File

@@ -1,6 +1,6 @@
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 { resourcesPath } from '../shared/utils';
@@ -43,8 +43,10 @@ function isNetworkResource(pathname: string) {
async function handleFileRequest(request: Request) {
const urlObject = new URL(request.url);
const isAbsolutePath = urlObject.host !== '.';
// Redirect to webpack dev server if defined
if (process.env.DEV_SERVER_URL) {
if (process.env.DEV_SERVER_URL && !isAbsolutePath) {
const devServerUrl = new URL(
urlObject.pathname,
process.env.DEV_SERVER_URL
@@ -56,20 +58,30 @@ async function handleFileRequest(request: Request) {
});
// this will be file types (in the web-static folder)
let filepath = '';
// if is a file type, load the file in resources
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
// Sanitize pathname to prevent path traversal attacks
const decodedPath = decodeURIComponent(urlObject.pathname);
const normalizedPath = join(webStaticDir, decodedPath).normalize();
if (!normalizedPath.startsWith(webStaticDir)) {
// Attempted path traversal - reject by using empty path
filepath = join(webStaticDir, '');
// for relative path, load the file in resources
if (!isAbsolutePath) {
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
// Sanitize pathname to prevent path traversal attacks
const decodedPath = decodeURIComponent(urlObject.pathname);
const normalizedPath = join(webStaticDir, decodedPath).normalize();
if (!normalizedPath.startsWith(webStaticDir)) {
// Attempted path traversal - reject by using empty path
filepath = join(webStaticDir, '');
} else {
filepath = normalizedPath;
}
} else {
filepath = normalizedPath;
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
}
} else {
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
filepath = decodeURIComponent(urlObject.pathname);
// 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);
}

View File

@@ -1,11 +1,13 @@
import path from 'node:path';
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 { BehaviorSubject, distinctUntilChanged, groupBy, mergeMap } from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import { getMainWindow } from '../windows-manager';
@@ -18,6 +20,11 @@ import type {
const subscribers: Subscriber[] = [];
const SAVED_RECORDINGS_DIR = path.join(
app.getPath('sessionData'),
'recordings'
);
beforeAppQuit(() => {
subscribers.forEach(subscriber => {
try {
@@ -134,7 +141,9 @@ function setupNewRunningAppGroup() {
recordingStatus$.value?.appGroup?.processGroupId ===
currentGroup.processGroupId
) {
stopRecording();
stopRecording().catch(err => {
logger.error('failed to stop recording', err);
});
}
}
})
@@ -142,7 +151,13 @@ function setupNewRunningAppGroup() {
}
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) {
const recordingStatus = recordingStatus$.getValue();
@@ -157,7 +172,9 @@ function createRecording(status: RecordingStatus) {
if (err) {
logger.error('failed to get audio samples', err);
} 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,
app: status.app,
appGroup: status.appGroup,
buffers,
file,
stream,
};
return recording;
}
function concatBuffers(buffers: Float32Array[]): Float32Array {
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) {
export async function getRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
logger.error(`Recording ${id} not found`);
return;
}
const { buffers } = recording;
const helperProcessManager = await ensureHelperProcess();
const buffer = concatBuffers(buffers);
const mp3Buffer = await helperProcessManager.rpc?.encodeToMp3(buffer, {
channels: recording.stream.channels,
const rawFilePath = String(recording.file.path);
return {
id,
appGroup: recording.appGroup,
app: recording.app,
startTime: recording.startTime,
filepath: rawFilePath,
sampleRate: recording.stream.sampleRate,
});
if (!mp3Buffer) {
logger.error('failed to encode audio samples to mp3');
return;
}
recordings.delete(recording.id);
return mp3Buffer;
numberOfChannels: recording.stream.channels,
};
}
function setupRecordingListeners() {
@@ -386,9 +388,15 @@ export function resumeRecording() {
});
}
export function stopRecording() {
export async function stopRecording() {
const recordingStatus = recordingStatus$.value;
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;
}
@@ -398,6 +406,15 @@ export function stopRecording() {
status: 'stopped',
});
const { file } = recording;
file.end();
await new Promise<void>(resolve => {
file.on('finish', () => {
resolve();
});
});
// bring up the window
getMainWindow()
.then(mainWindow => {
@@ -411,8 +428,17 @@ export function stopRecording() {
}
export const recordingHandlers = {
saveRecording: async (_, id: number) => {
return saveRecording(id);
getRecording: async (_, id: number) => {
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;

View File

@@ -1,3 +1,5 @@
import type { WriteStream } from 'node:fs';
import type { AudioTapStream, TappableApplication } from '@affine/native';
export interface TappableAppInfo {
@@ -23,8 +25,8 @@ export interface Recording {
// the app may not be available if the user choose to record system audio
app?: TappableAppInfo;
appGroup?: AppGroupInfo;
// the raw audio buffers that are already accumulated
buffers: Float32Array[];
// the buffered file that is being recorded streamed to
file: WriteStream;
stream: AudioTapStream;
startTime: number;
}

View File

@@ -198,7 +198,9 @@ class TrayState {
label: 'Stop',
click: () => {
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;
export const getTrayState = () => {
export const setupTrayState = () => {
if (!_trayState) {
_trayState = new TrayState();
_trayState.init();

View File

@@ -35,6 +35,12 @@ export const uiEvents = {
},
onTabsStatusChange,
onActiveTabChanged,
onTabGoToRequest: (fn: (opts: { tabId: string; to: string }) => void) => {
const sub = uiSubjects.tabGoToRequest$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabShellViewActiveChange,
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
const sub = uiSubjects.authenticationRequest$.subscribe(fn);

View File

@@ -10,6 +10,7 @@ import {
activateView,
addTab,
closeTab,
ensureTabLoaded,
getMainWindow,
getOnboardingWindow,
getTabsStatus,
@@ -187,6 +188,12 @@ export const uiHandlers = {
showTab: async (_, ...args: Parameters<typeof showTab>) => {
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>) => {
await closeTab(...args);
},

View File

@@ -6,6 +6,7 @@ export const uiSubjects = {
onMaximized$: new Subject<boolean>(),
onFullScreen$: new Subject<boolean>(),
onToggleRightSidebar$: new Subject<string>(),
tabGoToRequest$: new Subject<{ tabId: string; to: string }>(),
authenticationRequest$: new Subject<AuthenticationRequest>(),
// via menu -> close view (CMD+W)
onCloseView$: new Subject<void>(),

View File

@@ -24,7 +24,7 @@ import {
} from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { beforeAppQuit, onTabClose } from '../cleanup';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
@@ -400,6 +400,8 @@ export class WebContentViewsManager {
});
}
}, 500); // delay a bit to get rid of the flicker
onTabClose(id);
};
undoCloseTab = async () => {
@@ -1052,6 +1054,13 @@ export const loadUrlInActiveTab = async (_url: string) => {
// todo: implement
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 closeTab = WebContentViewsManager.instance.closeTab;
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;

View File

@@ -17,13 +17,6 @@ export interface HelperToRenderer {
// helper <-> main
export interface HelperToMain {
getMeta: () => ExposedMeta;
encodeToMp3: (
samples: Float32Array,
opts?: {
channels?: number;
sampleRate?: number;
}
) => Uint8Array;
}
export type MainToHelper = Pick<

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
*/
import type { PropertyDeclaration } from 'lit';
import type React from 'react';
const DEV_MODE = process.env.NODE_ENV !== 'production';
@@ -164,6 +165,7 @@ const setProperty = <E extends Element>(
name: string,
value: unknown,
old: unknown,
elementProperties?: Map<string, PropertyDeclaration>,
events?: EventNames
) => {
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.
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
// elements where `undefined` or `null` values result in attributes being
// removed.
@@ -302,6 +313,12 @@ export const createComponent = <
props[prop],
// @ts-expect-error: prop is a key of props
prevPropsRef.current ? prevPropsRef.current[prop] : undefined,
'elementProperties' in elementClass
? (elementClass.elementProperties as Map<
string,
PropertyDeclaration
>)
: undefined,
events
);
}

View File

@@ -25,6 +25,7 @@
"@emotion/react": "^11.14.0",
"@floating-ui/dom": "^1.6.13",
"@juggle/resize-observer": "^3.4.0",
"@lit/context": "^1.1.4",
"@marsidev/react-turnstile": "^1.1.0",
"@preact/signals-core": "^1.8.0",
"@radix-ui/react-collapsible": "^1.1.2",

View File

@@ -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`;
}),
];

View File

@@ -2,6 +2,7 @@ import { SpecProvider } from '@blocksuite/affine/shared/utils';
import { AIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-block';
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
import { LitTranscriptionBlock } from './blocks/ai-chat-block/ai-transcription-block';
import {
AIChatMessage,
AIChatMessages,
@@ -151,5 +152,7 @@ export function registerAIEffects() {
EdgelessCopilotToolbarEntry
);
customElements.define('transcription-block', LitTranscriptionBlock);
SpecProvider._.extendSpec('store', [AIChatBlockSchemaExtension]);
}

View File

@@ -9,6 +9,7 @@ import type { SpecBuilder } from '@blocksuite/affine/shared/utils';
import type { FrameworkProvider } from '@toeverything/infra';
import { AIChatBlockSpec } from '../blocks';
import { AITranscriptionBlockSpec } from '../blocks/ai-chat-block/ai-transcription-block';
import { AICodeBlockSpec } from './ai-code';
import { createAIEdgelessRootBlockSpec } from './ai-edgeless-root';
import { AIImageBlockSpec } from './ai-image';
@@ -39,4 +40,5 @@ export function enableAIExtension(
}
specBuilder.extend(AIChatBlockSpec);
specBuilder.extend(AITranscriptionBlockSpec);
}

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,5 @@
import { style } from '@vanilla-extract/css';
export const root = style({
borderRadius: 6,
});

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -7,7 +7,7 @@ import type { PropsWithChildren, ReactElement } from 'react';
import { Suspense } from 'react';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
import * as styles from './styles.css';
import * as styles from './error.css';
import { download } from './utils';
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
@@ -67,7 +67,7 @@ interface ErrorBaseProps {
buttons?: ReactElement[];
}
export const ErrorBase = ({
const ErrorBase = ({
title,
subtitle,
icon = <FileIcon />,
@@ -88,7 +88,7 @@ interface ErrorProps {
ext: string;
}
export const Error = ({ model, ext }: ErrorProps) => {
export const AttachmentFallback = ({ model, ext }: ErrorProps) => {
const t = useI18n();
const Icon = FILE_ICONS[model.props.type] ?? FileIcon;
const title = t['com.affine.attachment.preview.error.title']();

View File

@@ -1,11 +1,11 @@
import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
import { AttachmentPreviewErrorBoundary, Error } from './error';
import { PDFViewer } from './pdf-viewer';
import * as styles from './styles.css';
import { Titlebar } from './titlebar';
import type { AttachmentViewerProps, PDFViewerProps } from './types';
import { AttachmentFallback, AttachmentPreviewErrorBoundary } from './error';
import { PDFViewer } from './pdf/pdf-viewer';
import type { AttachmentViewerBaseProps, AttachmentViewerProps } from './types';
import { buildAttachmentProps } from './utils';
import { Titlebar } from './viewer';
import * as styles from './viewer.css';
// In Peek view
export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
@@ -35,12 +35,12 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
);
};
const AttachmentViewerInner = (props: PDFViewerProps) => {
const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => {
return props.model.props.type.endsWith('pdf') ? (
<AttachmentPreviewErrorBoundary>
<PDFViewer {...props} />
</AttachmentPreviewErrorBoundary>
) : (
<Error {...props} />
<AttachmentFallback {...props} />
);
};

View File

@@ -28,9 +28,8 @@ import {
useState,
} from 'react';
import type { AttachmentViewerProps } from '../types';
import * as styles from './styles.css';
import * as embeddedStyles from './styles.embedded.css';
import type { PDFViewerProps } from './types';
function defaultMeta() {
return {
@@ -40,7 +39,7 @@ function defaultMeta() {
};
}
export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
export function PDFViewerEmbedded({ model }: AttachmentViewerProps) {
const scale = window.devicePixelRatio;
const peekView = useService(PeekViewService).peekView;
const pdfService = useService(PDFService);
@@ -194,8 +193,8 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
}, []);
return (
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
<main className={embeddedStyles.pdfViewer}>
<div ref={viewerRef} className={styles.pdfContainer}>
<main className={styles.pdfViewer}>
<div
className={styles.pdfPage}
style={{
@@ -216,11 +215,11 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
/>
</div>
<div className={embeddedStyles.pdfControls}>
<div className={styles.pdfControls}>
<IconButton
size={16}
icon={<ArrowUpSmallIcon />}
className={embeddedStyles.pdfControlButton}
className={styles.pdfControlButton}
onDoubleClick={stopPropagation}
aria-label="Prev"
{...navigator.prev}
@@ -228,7 +227,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
<IconButton
size={16}
icon={<ArrowDownSmallIcon />}
className={embeddedStyles.pdfControlButton}
className={styles.pdfControlButton}
onDoubleClick={stopPropagation}
aria-label="Next"
{...navigator.next}
@@ -236,27 +235,18 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerProps) {
<IconButton
size={16}
icon={<CenterPeekIcon />}
className={embeddedStyles.pdfControlButton}
className={styles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.peek}
/>
</div>
</main>
<footer className={embeddedStyles.pdfFooter}>
<div
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
>
<footer className={styles.pdfFooter}>
<div className={clsx([styles.pdfFooterItem, { truncate: true }])}>
<AttachmentIcon />
<span className={clsx([embeddedStyles.pdfTitle, 'pdf-name'])}>
{name}
</span>
<span className={clsx([styles.pdfTitle, 'pdf-name'])}>{name}</span>
</div>
<div
className={clsx([
embeddedStyles.pdfFooterItem,
embeddedStyles.pdfPageCount,
])}
>
<div className={clsx([styles.pdfFooterItem, styles.pdfPageCount])}>
<span className="page-cursor">
{meta.pageCount > 0 ? cursor + 1 : '-'}
</span>

View File

@@ -1,21 +1,21 @@
import { IconButton, observeResize } from '@affine/component';
import type {
PDF,
PDFRendererState,
PDFStatus,
} from '@affine/core/modules/pdf';
import type { PDF, PDFRendererState } from '@affine/core/modules/pdf';
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
import {
Item,
List,
ListPadding,
ListWithSmallGap,
LoadingSvg,
PDFPageRenderer,
type PDFVirtuosoContext,
type PDFVirtuosoProps,
Scroller,
ScrollSeekPlaceholder,
} from '@affine/core/modules/pdf/views';
import track from '@affine/track';
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
@@ -24,11 +24,21 @@ import {
type VirtuosoHandle,
} from 'react-virtuoso';
import type { AttachmentViewerProps } from '../types';
import * as styles from './styles.css';
import { calculatePageNum, fitToPage } from './utils';
import { fitToPage } from './utils';
const THUMBNAIL_WIDTH = 94;
function calculatePageNum(el: HTMLElement, pageCount: number) {
const { scrollTop, scrollHeight } = el;
const pageHeight = scrollHeight / pageCount;
const n = scrollTop / pageHeight;
const t = n / pageCount;
const index = Math.floor(n + t);
const cursor = Math.min(index, pageCount - 1);
return cursor;
}
export interface PDFViewerInnerProps {
pdf: PDF;
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
@@ -234,3 +244,48 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
</div>
);
};
function PDFViewerStatus({
pdf,
...props
}: AttachmentViewerProps & { pdf: PDF }) {
const state = useLiveData(pdf.state$);
useEffect(() => {
if (state.status !== PDFStatus.Error) return;
track.$.attachment.$.openPDFRendererFail();
}, [state]);
if (state?.status !== PDFStatus.Opened) {
return <PDFLoading />;
}
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
}
export function PDFViewer({ model, ...props }: AttachmentViewerProps) {
const pdfService = useService(PDFService);
const [pdf, setPdf] = useState<PDF | null>(null);
useEffect(() => {
const { pdf, release } = pdfService.get(model);
setPdf(pdf);
return () => {
release();
};
}, [model, pdfService, setPdf]);
if (!pdf) {
return <PDFLoading />;
}
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
}
const PDFLoading = () => (
<div style={{ margin: 'auto' }}>
<LoadingSvg />
</div>
);

View File

@@ -1,115 +1,13 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const viewerContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
});
export const titlebar = style({
display: 'flex',
justifyContent: 'space-between',
height: '52px',
padding: '10px 8px',
background: cssVarV2('layer/background/primary'),
fontSize: '12px',
fontWeight: 400,
color: cssVarV2('text/secondary'),
borderTopWidth: '0.5px',
borderTopStyle: 'solid',
borderTopColor: cssVarV2('layer/insideBorder/border'),
textWrap: 'nowrap',
overflow: 'hidden',
});
export const titlebarChild = style({
overflow: 'hidden',
selectors: {
[`${titlebar} > &`]: {
display: 'flex',
gap: '12px',
alignItems: 'center',
paddingLeft: '12px',
paddingRight: '12px',
},
'&.zoom:not(.show)': {
display: 'none',
},
},
});
export const titlebarName = style({
display: 'flex',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'pre',
wordWrap: 'break-word',
});
export { viewer } from '../viewer.css';
export const virtuoso = style({
width: '100%',
});
export const error = style({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
});
export const errorTitle = style({
fontSize: '15px',
fontWeight: 500,
lineHeight: '24px',
color: cssVarV2('text/primary'),
marginTop: '12px',
});
export const errorMessage = style({
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/tertiary'),
});
export const errorBtns = style({
display: 'flex',
flexDirection: 'column',
gap: '10px',
marginTop: '28px',
});
export const viewer = style({
position: 'relative',
zIndex: 0,
display: 'flex',
flex: 1,
overflow: 'hidden',
resize: 'none',
selectors: {
'&:before': {
position: 'absolute',
content: '',
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: -1,
},
'&:not(.gridding):before': {
backgroundColor: cssVarV2('layer/background/secondary'),
},
'&.gridding:before': {
opacity: 0.25,
backgroundSize: '20px 20px',
backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`,
},
},
});
export const pdfIndicator = style({
display: 'flex',
alignItems: 'center',
@@ -184,3 +82,94 @@ export const pdfThumbnail = style({
},
},
});
export const pdfContainer = style({
width: '100%',
borderRadius: '8px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVarV2('layer/insideBorder/border'),
background: cssVar('--affine-background-primary-color'),
userSelect: 'none',
contentVisibility: 'visible',
display: 'flex',
minHeight: 'fit-content',
height: '100%',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const pdfViewer = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
overflow: 'hidden',
background: cssVarV2('layer/background/secondary'),
flex: 1,
});
export const pdfPlaceholder = style({
position: 'absolute',
maxWidth: 'calc(100% - 24px)',
overflow: 'hidden',
height: 'auto',
pointerEvents: 'none',
});
export const pdfControls = style({
position: 'absolute',
bottom: '16px',
right: '14px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
});
export const pdfControlButton = style({
width: '36px',
height: '36px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVar('--affine-border-color'),
background: cssVar('--affine-white'),
});
export const pdfFooter = style({
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
gap: '12px',
padding: '12px',
textWrap: 'nowrap',
});
export const pdfFooterItem = style({
display: 'flex',
alignItems: 'center',
selectors: {
'&.truncate': {
overflow: 'hidden',
},
},
});
export const pdfTitle = style({
marginLeft: '8px',
fontSize: '14px',
fontWeight: 600,
lineHeight: '22px',
color: cssVarV2('text/primary'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const pdfPageCount = style({
fontSize: '12px',
fontWeight: 400,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
});

View File

@@ -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,
};
}

View File

@@ -1,10 +1,12 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
export type AttachmentType = 'pdf' | 'image' | 'audio' | 'video' | 'unknown';
export type AttachmentViewerProps = {
model: AttachmentBlockModel;
};
export type PDFViewerProps = {
export type AttachmentViewerBaseProps = {
model: AttachmentBlockModel;
name: string;
ext: string;

View File

@@ -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';

View File

@@ -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',
});

View File

@@ -10,8 +10,8 @@ import {
import clsx from 'clsx';
import { useState } from 'react';
import * as styles from './styles.css';
import { download } from './utils';
import * as styles from './viewer.css';
const items = [
/*

View File

@@ -50,7 +50,10 @@ import {
type DefaultOpenProperty,
DocPropertiesTable,
} from '../../components/doc-properties';
import { patchForAttachmentEmbedViews } from '../extensions/attachment-embed-view';
import {
patchForAudioEmbedView,
patchForPDFEmbedView,
} from '../extensions/attachment-embed-view';
import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
import { patchDocModeService } from '../extensions/doc-mode-service';
import { patchDocUrlExtensions } from '../extensions/doc-url';
@@ -148,6 +151,14 @@ const usePatchSpecs = (mode: DocMode) => {
featureFlagService.flags.enable_turbo_renderer.$
);
const enablePDFEmbedPreview = useLiveData(
featureFlagService.flags.enable_pdf_embed_preview.$
);
const enableAudioBlock = useLiveData(
featureFlagService.flags.enable_audio_block.$
);
const patchedSpecs = useMemo(() => {
const builder = enableEditorExtension(framework, mode, enableAI);
@@ -176,8 +187,11 @@ const usePatchSpecs = (mode: DocMode) => {
].flat()
);
if (featureFlagService.flags.enable_pdf_embed_preview.value) {
builder.extend([patchForAttachmentEmbedViews(reactToLit)]);
if (enablePDFEmbedPreview) {
builder.extend([patchForPDFEmbedView(reactToLit)]);
}
if (enableAudioBlock) {
builder.extend([patchForAudioEmbedView(reactToLit)]);
}
if (BUILD_CONFIG.isMobileEdition) {
enableMobileExtension(builder, framework);
@@ -202,7 +216,8 @@ const usePatchSpecs = (mode: DocMode) => {
memberSearchService,
publicUserService,
enableTurboRenderer,
featureFlagService.flags.enable_pdf_embed_preview.value,
enablePDFEmbedPreview,
enableAudioBlock,
]);
return [

View File

@@ -1,11 +1,11 @@
import type { ElementOrFactory } from '@affine/component';
import { AttachmentEmbedPreview } from '@affine/core/components/attachment-viewer/pdf-viewer-embedded';
import { AttachmentEmbedPreview } from '@affine/core/blocksuite/attachment-viewer/attachment-embed-preview';
import { AttachmentEmbedConfigIdentifier } from '@blocksuite/affine/blocks/attachment';
import { Bound } from '@blocksuite/affine/global/gfx';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { TemplateResult } from 'lit';
export function patchForAttachmentEmbedViews(
export function patchForPDFEmbedView(
reactToLit: (
element: ElementOrFactory,
rerendering?: boolean
@@ -34,3 +34,23 @@ export function patchForAttachmentEmbedViews(
},
};
}
export function patchForAudioEmbedView(
reactToLit: (
element: ElementOrFactory,
rerendering?: boolean
) => TemplateResult
): ExtensionType {
return {
setup: di => {
di.override(AttachmentEmbedConfigIdentifier('audio'), () => ({
name: 'audio',
check: (model, maxFileSize) =>
model.props.type.startsWith('audio/') &&
model.props.size <= maxFileSize,
template: (model, _blobUrl) =>
reactToLit(<AttachmentEmbedPreview model={model} />, false),
}));
},
};
}

View File

@@ -21,6 +21,7 @@ import type { FrameworkProvider } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { AIChatBlockSpec } from '../../ai/blocks';
import { AITranscriptionBlockSpec } from '../../ai/blocks/ai-chat-block/ai-transcription-block';
import { buildDocDisplayMetaExtension } from '../display-meta';
import { getFontConfigExtension } from '../font-config';
import { patchPeekViewService } from '../peek-view-service';
@@ -104,6 +105,7 @@ export function enablePreviewExtension(framework: FrameworkProvider): void {
_previewExtensions = [
...AIChatBlockSpec,
...AITranscriptionBlockSpec,
fontConfig,
getThemeExtension(framework),
getPagePreviewThemeExtension(framework),

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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'),
});

View File

@@ -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,
};
}

View File

@@ -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',
},
},
});

View File

@@ -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>
);
};

View File

@@ -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
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from './audio-player';

View File

@@ -0,0 +1,3 @@
import { style } from '@vanilla-extract/css';
export const root = style({});

View File

@@ -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} />;
};

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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);
};

View File

@@ -99,3 +99,7 @@ export const freeTag = style({
color: cssVar('pureWhite'),
background: cssVar('primaryColor'),
});
export const bottomContainer = style({
gap: 8,
});

View File

@@ -38,6 +38,7 @@ import { memo, useCallback } from 'react';
import { WorkbenchService } from '../../modules/workbench';
import { WorkspaceNavigator } from '../workspace-selector';
import {
bottomContainer,
quickSearch,
quickSearchAndNewPage,
workspaceAndUserWrapper,
@@ -45,6 +46,7 @@ import {
} from './index.css';
import { AppSidebarJournalButton } from './journal-button';
import { NotificationButton } from './notification-button';
import { SidebarAudioPlayer } from './sidebar-audio-player';
import { TemplateDocEntrance } from './template-doc-entrance';
import { TrashButton } from './trash-button';
import { UpdaterButton } from './updater-button';
@@ -204,7 +206,8 @@ export const RootAppSidebar = memo((): ReactElement => {
/>
</CollapsibleSection>
</SidebarScrollableContainer>
<SidebarContainer>
<SidebarContainer className={bottomContainer}>
<SidebarAudioPlayer />
{BUILD_CONFIG.isElectron ? <UpdaterButton /> : <AppDownloadButton />}
</SidebarContainer>
</AppSidebar>

View File

@@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
});

View File

@@ -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>
);
};

View File

@@ -1,11 +1,11 @@
import { Skeleton } from '@affine/component';
import { AttachmentViewer } from '@affine/core/blocksuite/attachment-viewer';
import { type Doc, DocsService } from '@affine/core/modules/doc';
import { type AttachmentBlockModel } from '@blocksuite/affine/model';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import { type ReactElement, useLayoutEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AttachmentViewerView } from '../../../../components/attachment-viewer';
import { ViewIcon, ViewTitle } from '../../../../modules/workbench';
import { PageNotFound } from '../../404';
import * as styles from './index.css';
@@ -71,7 +71,7 @@ export const AttachmentPage = ({
<ViewIcon
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
/>
<AttachmentViewerView model={model} />
<AttachmentViewer model={model} />
</FrameworkScope>
);
}

View File

@@ -5,14 +5,28 @@ import { type PropsWithChildren } from 'react';
import * as styles from './index.css';
export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>;
interface SidebarContainerProps extends PropsWithChildren {
className?: string;
}
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
export function SidebarContainer({
children,
className,
}: SidebarContainerProps) {
return (
<div className={clsx([styles.baseContainer, className])}>{children}</div>
);
}
export function SidebarScrollableContainer({
children,
className,
}: SidebarContainerProps) {
const [setContainer, hasScrollTop] = useHasScrollTop();
return (
<ScrollArea.Root className={styles.scrollableContainerRoot}>
<ScrollArea.Root
className={clsx([styles.scrollableContainerRoot, className])}
>
<div
data-has-scroll-top={hasScrollTop}
className={styles.scrollTopBorder}

View File

@@ -1,6 +1,7 @@
import { notify } from '@affine/component';
import { I18n } from '@affine/i18n';
import { OnEvent, Service } from '@toeverything/infra';
import type { To } from 'history';
import { debounce } from 'lodash-es';
import { AuthService, DefaultServerService, ServersService } from '../../cloud';
@@ -32,6 +33,25 @@ export class DesktopApiService extends Service {
return this.api.sharedStorage;
}
async showTab(tabId: string, to?: To) {
if (to) {
const url = new URL(to.toString());
const tabs = await this.api.handler.ui.getTabViewsMeta();
const tab = tabs.workbenches.find(t => t.id === tabId);
if (tab) {
const basename = tab.basename;
if (url.pathname.startsWith(basename)) {
const pathname = url.pathname.slice(basename.length);
await this.api.handler.ui.tabGoTo(
tabId,
pathname + url.search + url.hash
);
}
}
}
await this.api.handler.ui.showTab(tabId);
}
private setupStartListener() {
this.setupCommonUIEvents();
this.setupAuthRequestEvent();

View File

@@ -243,6 +243,15 @@ export const AFFINE_FLAGS = {
configurable: !isMobile,
defaultState: false,
},
enable_audio_block: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-audio-block.name',
description:
'com.affine.settings.workspace.experimental-features.enable-audio-block.description',
configurable: !isMobile,
defaultState: false,
},
enable_editor_rtl: {
category: 'affine',
displayName:

View File

@@ -30,6 +30,7 @@ import { configureImportTemplateModule } from './import-template';
import { configureIntegrationModule } from './integration';
import { configureJournalModule } from './journal';
import { configureLifecycleModule } from './lifecycle';
import { configureMediaModule } from './media';
import { configureNavigationModule } from './navigation';
import { configureNotificationModule } from './notification';
import { configureOpenInApp } from './open-in-app';
@@ -103,6 +104,7 @@ export function configureCommonModules(framework: Framework) {
configureAIButtonModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);
configureMediaModule(framework);
configureImportClipperModule(framework);
configureNotificationModule(framework);
configureIntegrationModule(framework);

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,46 @@
import type { Framework } from '@toeverything/infra';
import { DesktopApiService } from '../desktop-api';
import { GlobalState } from '../storage';
import { WorkbenchService } from '../workbench';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { AudioAttachmentBlock } from './entities/audio-attachment-block';
import { AudioMedia } from './entities/audio-media';
import {
ElectronGlobalMediaStateProvider,
GlobalMediaStateProvider,
WebGlobalMediaStateProvider,
} from './providers/global-audio-state';
import { AudioAttachmentService } from './services/audio-attachment';
import { AudioMediaManagerService } from './services/audio-media-manager';
export function configureMediaModule(framework: Framework) {
if (BUILD_CONFIG.isElectron) {
framework
.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
GlobalState,
])
.scope(WorkspaceScope)
.entity(AudioMedia, [WorkspaceService])
.entity(AudioAttachmentBlock, [AudioMediaManagerService])
.service(AudioMediaManagerService, [
GlobalMediaStateProvider,
WorkbenchService,
DesktopApiService,
])
.service(AudioAttachmentService);
} else {
framework
.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider)
.scope(WorkspaceScope)
.entity(AudioMedia, [WorkspaceService])
.entity(AudioAttachmentBlock, [AudioMediaManagerService])
.service(AudioMediaManagerService, [
GlobalMediaStateProvider,
WorkbenchService,
])
.service(AudioAttachmentService);
}
}
export { AudioMedia, AudioMediaManagerService };

View File

@@ -0,0 +1,126 @@
import {
createIdentifier,
LiveData,
type MediaStats,
type PlaybackState,
} from '@toeverything/infra';
import type { GlobalState } from '../../storage';
const GLOBAL_MEDIA_PLAYBACK_STATE_KEY = 'media:playback-state';
const GLOBAL_MEDIA_STATS_KEY = 'media:stats';
export const GlobalMediaStateProvider =
createIdentifier<BaseGlobalMediaStateProvider>('GlobalMediaStateProvider');
/**
* Base class for media state providers
*/
export abstract class BaseGlobalMediaStateProvider {
abstract readonly playbackState$: LiveData<PlaybackState | null | undefined>;
abstract readonly stats$: LiveData<MediaStats | null | undefined>;
/**
* Update the playback state
* @param state Full state object or partial state to update
*/
abstract updatePlaybackState(state: Partial<PlaybackState> | null): void;
/**
* Update the media stats
* @param stats Full stats object or partial stats to update
*/
abstract updateStats(stats: Partial<MediaStats> | null): void;
}
/**
* Provider for global media state in Electron environment
* This ensures only one media is playing at a time across all tabs
*/
export class ElectronGlobalMediaStateProvider extends BaseGlobalMediaStateProvider {
constructor(private readonly globalState: GlobalState) {
super();
}
readonly playbackState$ = LiveData.from<PlaybackState | null | undefined>(
this.globalState.watch(GLOBAL_MEDIA_PLAYBACK_STATE_KEY),
this.globalState.get(GLOBAL_MEDIA_PLAYBACK_STATE_KEY)
);
readonly stats$ = LiveData.from<MediaStats | null | undefined>(
this.globalState.watch(GLOBAL_MEDIA_STATS_KEY),
this.globalState.get(GLOBAL_MEDIA_STATS_KEY)
);
override updatePlaybackState(state: Partial<PlaybackState> | null): void {
if (state === null) {
this.globalState.set(GLOBAL_MEDIA_PLAYBACK_STATE_KEY, null);
return;
}
const currentState = this.playbackState$.value;
const newState = currentState
? { ...currentState, ...state }
: (state as PlaybackState);
this.globalState.set(GLOBAL_MEDIA_PLAYBACK_STATE_KEY, newState);
}
override updateStats(stats: Partial<MediaStats> | null): void {
if (stats === null) {
this.globalState.set(GLOBAL_MEDIA_STATS_KEY, null);
return;
}
const currentStats = this.stats$.value;
const newStats = currentStats
? { ...currentStats, ...stats }
: (stats as MediaStats);
this.globalState.set(GLOBAL_MEDIA_STATS_KEY, newStats);
}
}
/**
* Provider for global media state in Web environment
* This is a simplified version that only works within the current tab
*/
export class WebGlobalMediaStateProvider extends BaseGlobalMediaStateProvider {
readonly playbackState$ = new LiveData<PlaybackState | null | undefined>(
null
);
readonly stats$ = new LiveData<MediaStats | null | undefined>(null);
/**
* Update the playback state
*/
override updatePlaybackState(state: Partial<PlaybackState> | null): void {
if (state === null) {
this.playbackState$.setValue(null);
return;
}
const currentState = this.playbackState$.value;
const newState = currentState
? { ...currentState, ...state }
: (state as PlaybackState);
this.playbackState$.setValue(newState);
}
/**
* Update the media stats
*/
override updateStats(stats: Partial<MediaStats> | null): void {
if (stats === null) {
this.stats$.setValue(null);
return;
}
const currentStats = this.stats$.value;
const newStats = currentStats
? { ...currentStats, ...stats }
: (stats as MediaStats);
this.stats$.setValue(newStats);
}
}

View File

@@ -0,0 +1,41 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import {
attachmentBlockAudioMediaKey,
type AudioMediaKey,
ObjectPool,
Service,
} from '@toeverything/infra';
import { AudioAttachmentBlock } from '../entities/audio-attachment-block';
export class AudioAttachmentService extends Service {
private readonly pool = new ObjectPool<AudioMediaKey, AudioAttachmentBlock>({
onDelete: block => {
block.dispose();
},
onDangling: block => {
return !block.rendering$.value;
},
});
get(model: AttachmentBlockModel | AudioMediaKey) {
if (typeof model === 'string') {
return this.pool.get(model);
}
if (!model.props.sourceId) {
throw new Error('Source ID is required');
}
const key = attachmentBlockAudioMediaKey({
blobId: model.props.sourceId,
blockId: model.id,
docId: model.doc.id,
workspaceId: model.doc.rootDoc.guid,
});
let exists = this.pool.get(key);
if (!exists) {
const entity = this.framework.createEntity(AudioAttachmentBlock, model);
exists = this.pool.put(key, entity);
}
return exists;
}
}

View File

@@ -0,0 +1,357 @@
import { generateUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { AttachmentBlockModel } from '@blocksuite/affine/model';
import {
attachmentBlockAudioMediaKey,
type AudioMediaDescriptor,
type AudioMediaKey,
type MediaStats,
ObjectPool,
parseAudioMediaKey,
type PlaybackState,
Service,
} from '@toeverything/infra';
import { clamp } from 'lodash-es';
import { distinctUntilChanged } from 'rxjs';
import type { DesktopApiService } from '../../desktop-api';
import type { WorkbenchService } from '../../workbench';
import { AudioMedia } from '../entities/audio-media';
import type { BaseGlobalMediaStateProvider } from '../providers/global-audio-state';
// Media service is to control how media should be played for attachment block
// At a time, only one media can be played.
export class AudioMediaManagerService extends Service {
private readonly mediaPool = new ObjectPool<AudioMediaKey, AudioMedia>({
onDelete: media => {
media.dispose();
const disposables = this.mediaDisposables.get(media);
if (disposables) {
disposables.forEach(dispose => dispose());
this.mediaDisposables.delete(media);
}
},
onDangling: media => {
return media.playbackState$.getValue().state !== 'playing';
},
});
private readonly mediaDisposables = new WeakMap<AudioMedia, (() => void)[]>();
constructor(
private readonly globalMediaState: BaseGlobalMediaStateProvider,
private readonly workbench: WorkbenchService,
private readonly desktopApi?: DesktopApiService
) {
super();
if (!BUILD_CONFIG.isElectron) {
this.desktopApi = undefined;
}
this.disposables.push(() => {
this.mediaPool.clear();
});
// Subscribe to global playback state changes to manage playback across tabs
this.disposables.push(
this.observeGlobalPlaybackState(state => {
if (!state) {
// If global state is cleared, stop all media
this.stopAllMedia();
return;
}
const activeStats = this.getGlobalMediaStats();
if (!activeStats) return;
if (
BUILD_CONFIG.isElectron &&
activeStats.tabId !== this.desktopApi?.appInfo.viewId
) {
// other tab is playing, pause the current media
if (state.state === 'playing') {
this.pauseAllMedia();
}
return;
}
const mediaRef = this.ensureMediaEntity(activeStats);
const media = mediaRef.media;
this.ensureExclusivePlayback();
media.syncState(state);
// Return cleanup function
return () => {
mediaRef.release();
};
})
);
window.addEventListener('beforeunload', () => {
this.stopAllMedia();
});
}
// Helper method to observe global playback state changes
private observeGlobalPlaybackState(
callback: (state: PlaybackState | undefined) => (() => void) | undefined
): () => void {
const unsubscribe = this.globalMediaState.playbackState$
.pipe(distinctUntilChanged((a, b) => a?.updateTime === b?.updateTime))
.subscribe(state => {
if (state) {
return callback(state);
}
return;
});
return () => {
unsubscribe.unsubscribe();
};
}
get playbackState$() {
return this.globalMediaState.playbackState$;
}
get playbackStats$() {
return this.globalMediaState.stats$;
}
ensureMediaEntity(input: AttachmentBlockModel | MediaStats) {
const descriptor = this.normalizeEntityDescriptor(input);
let rc = this.mediaPool.get(descriptor.key);
if (!rc) {
rc = this.mediaPool.put(
descriptor.key,
this.framework.createEntity(AudioMedia, {
blobId: descriptor.blobId,
metadata: new MediaMetadata({
title: descriptor.name,
artist: 'AFFiNE',
// todo: add artwork, like the app icon?
}),
})
);
const audioMedia = rc.obj;
// Set up playback state synchronization (broadcast to global state)
const playbackStateSubscription = audioMedia.playbackState$
.pipe(distinctUntilChanged((a, b) => a.updateTime === b.updateTime))
.subscribe(state => {
if (state.state === 'playing') {
this.globalMediaState.updateStats({
...audioMedia.getStats(),
tabId: descriptor.tabId,
key: descriptor.key,
name: descriptor.name,
size: descriptor.size,
});
this.globalMediaState.updatePlaybackState({
tabId: descriptor.tabId,
key: descriptor.key,
...audioMedia.getPlaybackStateData(),
});
} else if (
(state.state === 'paused' || state.state === 'stopped') &&
this.globalMediaState.stats$.value?.key === descriptor.key
) {
// If this is the active media and it's paused/stopped, update global state
this.globalMediaState.updatePlaybackState({
tabId: descriptor.tabId,
key: descriptor.key,
...audioMedia.getPlaybackStateData(),
});
if (state.state === 'stopped') {
this.globalMediaState.updateStats(null);
this.globalMediaState.updatePlaybackState(null);
}
}
});
this.mediaDisposables.set(audioMedia, [
() => playbackStateSubscription.unsubscribe(),
() => {
// if the audioMedia is the active media, remove it
if (this.getActiveMediaKey() === descriptor.key) {
this.globalMediaState.updatePlaybackState(null);
this.globalMediaState.updateStats(null);
}
},
]);
}
return { media: rc.obj, release: rc.release };
}
play() {
const stats = this.getGlobalMediaStats();
const currentState = this.getGlobalPlaybackState();
if (!stats || !currentState) {
return;
}
const seekOffset =
currentState.seekOffset + (Date.now() - currentState.updateTime) / 1000;
this.globalMediaState.updatePlaybackState({
state: 'playing',
// rewind to the beginning if the seek offset is greater than the duration
seekOffset: seekOffset >= stats.duration ? 0 : seekOffset,
updateTime: Date.now(),
});
}
pause() {
const state = this.getGlobalPlaybackState();
if (!state) {
return;
}
this.globalMediaState.updatePlaybackState({
state: 'paused',
seekOffset: (Date.now() - state.updateTime) / 1000 + state.seekOffset,
updateTime: Date.now(),
});
}
stop() {
this.globalMediaState.updatePlaybackState({
state: 'stopped',
seekOffset: 0,
updateTime: Date.now(),
});
}
seekTo(time: number) {
const stats = this.getGlobalMediaStats();
if (!stats) {
return;
}
this.globalMediaState.updatePlaybackState({
seekOffset: clamp(0, time, stats.duration),
updateTime: Date.now(),
});
}
focusAudioMedia(key: AudioMediaKey, tabId: string | null) {
const mediaProps = parseAudioMediaKey(key);
if (tabId === this.currentTabId) {
this.workbench.workbench.openDoc({
docId: mediaProps.docId,
mode: 'page',
blockIds: [mediaProps.blockId],
});
} else if (BUILD_CONFIG.isElectron && tabId) {
const url = generateUrl({
baseUrl: window.location.origin,
workspaceId: mediaProps.workspaceId,
pageId: mediaProps.docId,
blockIds: [mediaProps.blockId],
});
this.desktopApi?.showTab(tabId, url).catch(console.error);
}
}
private getActiveMediaKey(): AudioMediaKey | null {
const stats = this.getGlobalMediaStats();
return stats?.key || null;
}
private getGlobalPlaybackState(): PlaybackState | null {
const provider = this.globalMediaState;
return provider.playbackState$.value || null;
}
private getGlobalMediaStats(): MediaStats | null {
const provider = this.globalMediaState;
return provider.stats$.value || null;
}
// Ensure only one media is playing at a time
private ensureExclusivePlayback() {
const activeKey = this.getActiveMediaKey();
if (activeKey) {
this.pauseAllMedia(activeKey);
}
}
get currentTabId() {
return this.desktopApi?.appInfo.viewId || 'web';
}
private normalizeEntityDescriptor(
input: AttachmentBlockModel | MediaStats
): AudioMediaDescriptor {
if (input instanceof AttachmentBlockModel) {
if (!input.props.sourceId) {
throw new Error('Invalid media');
}
return {
key: attachmentBlockAudioMediaKey({
blobId: input.props.sourceId,
blockId: input.id,
docId: input.doc.id,
workspaceId: input.doc.rootDoc.guid,
}),
name: input.props.name,
size: input.props.size,
blobId: input.props.sourceId,
// when input is AttachmentBlockModel, it is always in the current tab
tabId: this.currentTabId,
};
} else {
const { blobId } = parseAudioMediaKey(input.key);
return {
key: input.key,
name: input.name,
size: input.size,
blobId,
tabId: input.tabId,
};
}
}
/**
* Pause all playing media except the one with the given ID
* IN THE CURRENT TAB
*/
private pauseAllMedia(exceptId?: AudioMediaKey) {
// Iterate through all objects in the pool
for (const [id, ref] of this.mediaPool.objects) {
if (
id !== exceptId &&
ref.obj.playbackState$.getValue().state === 'playing'
) {
ref.obj.pause();
}
}
}
private stopAllMedia(exceptId?: AudioMediaKey) {
// Iterate through all objects in the pool
for (const [id, ref] of this.mediaPool.objects) {
if (
id !== exceptId &&
ref.obj.playbackState$.getValue().state === 'playing'
) {
ref.obj.stop();
}
}
// The media entity may not being created yet
// so we need to change the state
const globalState = this.getGlobalPlaybackState();
if (
globalState &&
globalState.key !== exceptId &&
globalState.tabId === this.currentTabId
) {
this.globalMediaState.updatePlaybackState(null);
this.globalMediaState.updateStats(null);
}
}
}

View File

@@ -0,0 +1,70 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
export function getAttachmentType(model: AttachmentBlockModel) {
// Check MIME type first
if (model.props.type.startsWith('image/')) {
return 'image';
}
if (model.props.type.startsWith('audio/')) {
return 'audio';
}
if (model.props.type.startsWith('video/')) {
return 'video';
}
if (model.props.type === 'application/pdf') {
return 'pdf';
}
// If MIME type doesn't match, check file extension
const ext = model.props.name.split('.').pop()?.toLowerCase() || '';
if (
[
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'avif',
'tiff',
'bmp',
].includes(ext)
) {
return 'image';
}
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) {
return 'audio';
}
if (
['mp4', 'webm', 'avi', 'mov', 'mkv', 'mpeg', 'ogv', '3gp'].includes(ext)
) {
return 'video';
}
if (ext === 'pdf') {
return 'pdf';
}
return 'unknown';
}
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
const sourceId = model.props.sourceId;
if (!sourceId) {
throw new Error('Attachment not found');
}
const blob = await model.doc.blobSync.get(sourceId);
if (!blob) {
throw new Error('Attachment not found');
}
const arrayBuffer = await blob.arrayBuffer();
return arrayBuffer;
}

View File

@@ -0,0 +1,31 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import { useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import type { AudioAttachmentBlock } from '../entities/audio-attachment-block';
import { AudioAttachmentService } from '../services/audio-attachment';
export const useAttachmentMediaBlock = (model: AttachmentBlockModel) => {
const audioAttachmentService = useService(AudioAttachmentService);
const [audioAttachmentBlock, setAttachmentMedia] = useState<
AudioAttachmentBlock | undefined
>(undefined);
useEffect(() => {
if (!model.props.sourceId) {
return;
}
const entity = audioAttachmentService.get(model);
if (!entity) {
return;
}
const audioAttachmentBlock = entity.obj;
setAttachmentMedia(audioAttachmentBlock);
audioAttachmentBlock.mount();
return () => {
audioAttachmentBlock.unmount();
entity.release();
};
}, [audioAttachmentService, model]);
return audioAttachmentBlock;
};

View File

@@ -2,8 +2,9 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
import { catchError, from, map, of, startWith, switchMap } from 'rxjs';
import { downloadBlobToBuffer } from '../../media/utils';
import type { PDFMeta } from '../renderer';
import { downloadBlobToBuffer, PDFRenderer } from '../renderer';
import { PDFRenderer } from '../renderer';
import { PDFPage } from './pdf-page';
export enum PDFStatus {

View File

@@ -1,3 +1,2 @@
export { PDFRenderer } from './renderer';
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
export { downloadBlobToBuffer } from './utils';

View File

@@ -1,15 +0,0 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
const sourceId = model.props.sourceId;
if (!sourceId) {
throw new Error('Attachment not found');
}
const blob = await model.doc.blobSync.get(sourceId);
if (!blob) {
throw new Error('Attachment not found');
}
return await blob.arrayBuffer();
}

View File

@@ -1,7 +1,7 @@
import { AttachmentViewer } from '@affine/core/blocksuite/attachment-viewer';
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import { useMemo } from 'react';
import { AttachmentViewer } from '../../../../components/attachment-viewer';
import { useEditor } from '../utils';
export type AttachmentPreviewModalProps = {

View File

@@ -111,7 +111,6 @@ export const PeekViewManagerModal = () => {
}, []);
const onAnimationEnd = useCallback(() => {
console.log('onAnimationEnd');
setAnimating(false);
}, []);

View File

@@ -84,6 +84,12 @@ export class DesktopStateSynchronizer extends Service {
}
});
this.electronApi.events.ui.onTabGoToRequest(opts => {
if (opts.tabId === appInfo?.viewId) {
this.workbenchService.workbench.open(opts.to);
}
});
// sync workbench state with main process
// also fill tab view meta with title & moduleName
LiveData.computed(get => {

View File

@@ -1,26 +1,26 @@
{
"ar": 96,
"ar": 95,
"ca": 4,
"da": 5,
"de": 96,
"el-GR": 96,
"el-GR": 95,
"en": 100,
"es-AR": 96,
"es-CL": 97,
"es": 96,
"fa": 96,
"fr": 96,
"es": 95,
"fa": 95,
"fr": 95,
"hi": 2,
"it-IT": 96,
"it": 1,
"ja": 96,
"ja": 95,
"ko": 60,
"pl": 96,
"pt-BR": 96,
"ru": 96,
"sv-SE": 96,
"uk": 96,
"pl": 95,
"pt-BR": 95,
"ru": 95,
"sv-SE": 95,
"uk": 95,
"ur": 2,
"zh-Hans": 96,
"zh-Hant": 96
"zh-Hans": 95,
"zh-Hant": 95
}

View File

@@ -5460,7 +5460,7 @@ export function useAFFiNEI18N(): {
*/
["com.affine.settings.workspace.experimental-features.enable-callout.name"](): string;
/**
* `Let your words stand out.`
* `Let your words stand out. This also include the callout in the transcription block.`
*/
["com.affine.settings.workspace.experimental-features.enable-callout.description"](): string;
/**
@@ -5567,6 +5567,14 @@ export function useAFFiNEI18N(): {
* `Once enabled, you can preview PDF in embed view.`
*/
["com.affine.settings.workspace.experimental-features.enable-pdf-embed-preview.description"](): string;
/**
* `Audio block`
*/
["com.affine.settings.workspace.experimental-features.enable-audio-block.name"](): string;
/**
* `Audio block allows you to play audio files globally and add notes to them.`
*/
["com.affine.settings.workspace.experimental-features.enable-audio-block.description"](): string;
/**
* `Editor RTL`
*/
@@ -7313,6 +7321,14 @@ export function useAFFiNEI18N(): {
* `Source`
*/
["com.affine.integration.readwise-prop.source"](): string;
/**
* `Notes`
*/
["com.affine.attachmentViewer.audio.notes"](): string;
/**
* `Transcribing`
*/
["com.affine.attachmentViewer.audio.transcribing"](): string;
/**
* `An internal error occurred.`
*/

View File

@@ -1361,7 +1361,7 @@
"com.affine.settings.workspace.experimental-features.enable-block-meta.name": "Block Meta",
"com.affine.settings.workspace.experimental-features.enable-block-meta.description": "Once enabled, all blocks will have created time, updated time, created by and updated by.",
"com.affine.settings.workspace.experimental-features.enable-callout.name": "Callout",
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out.",
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out. This also include the callout in the transcription block.",
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "Embed Iframe Block",
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Enables Embed Iframe Block.",
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
@@ -1388,6 +1388,8 @@
"com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.description": "Once enabled, users can edit edgeless canvas.",
"com.affine.settings.workspace.experimental-features.enable-pdf-embed-preview.name": "PDF embed preview",
"com.affine.settings.workspace.experimental-features.enable-pdf-embed-preview.description": "Once enabled, you can preview PDF in embed view.",
"com.affine.settings.workspace.experimental-features.enable-audio-block.name": "Audio block",
"com.affine.settings.workspace.experimental-features.enable-audio-block.description": "Audio block allows you to play audio files globally and add notes to them.",
"com.affine.settings.workspace.experimental-features.enable-editor-rtl.name": "Editor RTL",
"com.affine.settings.workspace.experimental-features.enable-editor-rtl.description": "Once enabled, the editor will be displayed in RTL mode.",
"com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.",
@@ -1821,6 +1823,8 @@
"com.affine.integration.readwise.import.abort-notify-desc": "Import aborted, with {{finished}} highlights processed",
"com.affine.integration.readwise-prop.author": "Author",
"com.affine.integration.readwise-prop.source": "Source",
"com.affine.attachmentViewer.audio.notes": "Notes",
"com.affine.attachmentViewer.audio.transcribing": "Transcribing",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",

View File

@@ -12,6 +12,7 @@
"@google/generative-ai": "^0.24.0",
"@tailwindcss/vite": "^4.0.6",
"@types/express": "^4",
"@types/lodash-es": "^4.17.12",
"@types/multer": "^1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
@@ -22,6 +23,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.1.5",
"fs-extra": "^11.3.0",
"lodash-es": "^4.17.21",
"multer": "^1.4.5-lts.1",
"openai": "^4.85.1",
"react": "^19.0.0",

Some files were not shown because too many files have changed in this diff Show More