mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08:00
feat(electron): audio capture permissions and settings (#11185)
fix AF-2420, AF-2391, AF-2265
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
@@ -24,14 +25,14 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
events?.applicationMenu.openAboutPageInSettingModal(() => {
|
||||
events?.applicationMenu.openInSettingModal(activeTab => {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
workspace.scope.get(WorkspaceDialogService).open('setting', {
|
||||
activeTab: 'about',
|
||||
activeTab: activeTab as unknown as SettingTab,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
@@ -34,6 +35,8 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// maybe the workspace is not ready yet, eg. for shared workspace view
|
||||
await apis?.recording.handleBlockCreationFailed(status.id);
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
@@ -89,6 +92,16 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
model.props.sourceId = blobId;
|
||||
model.props.embed = true;
|
||||
|
||||
const meetingSettingsService = frameworkProvider.get(
|
||||
MeetingSettingsService
|
||||
);
|
||||
|
||||
if (!meetingSettingsService.settings.autoTranscription) {
|
||||
// auto transcription is disabled,
|
||||
// so we don't need to transcribe the recording by default
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
@@ -100,8 +113,23 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
audioAttachment?.obj.transcribe().catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
} else {
|
||||
throw new Error('No attachment model found');
|
||||
}
|
||||
})().catch(console.error);
|
||||
})()
|
||||
.then(async () => {
|
||||
await apis?.recording.handleBlockCreationSuccess(status.id);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to transcribe recording', error);
|
||||
return apis?.recording.handleBlockCreationFailed(
|
||||
status.id,
|
||||
error
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('unknown error', error);
|
||||
});
|
||||
},
|
||||
};
|
||||
const page = docsService.createDoc({ docProps, primaryMode: 'page' });
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
|
||||
|
||||
/**
|
||||
* Encodes raw audio data to Opus in WebM container.
|
||||
*/
|
||||
export 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: 128000,
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import { encodeRawBufferToOpus } from '@affine/core/utils/webm-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { encodeRawBufferToOpus } from './encode';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
filepath?: string;
|
||||
};
|
||||
|
||||
export const useRecordingStatus = () => {
|
||||
@@ -23,12 +31,12 @@ export const useRecordingStatus = () => {
|
||||
// Get initial status
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => setStatus(status as Status))
|
||||
.then(status => setStatus(status satisfies Status | null))
|
||||
.catch(console.error);
|
||||
|
||||
// Subscribe to status changes
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status =>
|
||||
setStatus(status as Status)
|
||||
setStatus(status satisfies Status | null)
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -51,15 +59,24 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (status.status === 'ready') {
|
||||
return t['com.affine.recording.ready']();
|
||||
} else if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
appName: status.appName,
|
||||
});
|
||||
} else {
|
||||
return t['com.affine.recording.recording.unnamed']();
|
||||
} else if (status.status === 'create-block-success') {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'recording' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'stopped'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
appName: status.appName,
|
||||
});
|
||||
} else {
|
||||
return t['com.affine.recording.recording.unnamed']();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [status, t]);
|
||||
|
||||
const handleDismiss = useAsyncCallback(async () => {
|
||||
@@ -96,7 +113,7 @@ export function Recording() {
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 1000); // wait at least 1 second for better user experience
|
||||
}, 500); // wait at least 500ms for better user experience
|
||||
}),
|
||||
]);
|
||||
await apis?.recording.readyRecording(result.id, buffer);
|
||||
@@ -125,6 +142,13 @@ export function Recording() {
|
||||
await apis?.recording?.startRecording(status.appGroupId);
|
||||
}, [status]);
|
||||
|
||||
const handleOpenFile = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
await apis?.recording?.showSavedRecordings(status.filepath);
|
||||
}, [status]);
|
||||
|
||||
const controlsElement = useMemo(() => {
|
||||
if (!status) {
|
||||
return null;
|
||||
@@ -150,7 +174,7 @@ export function Recording() {
|
||||
{t['com.affine.recording.stop']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'stopped') {
|
||||
} else if (status.status === 'stopped' || status.status === 'ready') {
|
||||
return (
|
||||
<Button
|
||||
variant="error"
|
||||
@@ -159,15 +183,33 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (status.status === 'ready') {
|
||||
} else if (status.status === 'create-block-success') {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.ready']()}
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
return (
|
||||
<>
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.dismiss']()}
|
||||
</Button>
|
||||
<Button variant="error" onClick={handleOpenFile}>
|
||||
{t['com.affine.recording.failed.button']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [handleDismiss, handleStartRecording, handleStopRecording, status, t]);
|
||||
}, [
|
||||
handleDismiss,
|
||||
handleOpenFile,
|
||||
handleStartRecording,
|
||||
handleStopRecording,
|
||||
status,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
|
||||
@@ -169,6 +169,10 @@ export default {
|
||||
],
|
||||
executableName: productName,
|
||||
asar: true,
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription:
|
||||
'Please allow access in order to capture audio from other apps by AFFiNE.',
|
||||
},
|
||||
},
|
||||
makers,
|
||||
plugins: [{ name: '@electron-forge/plugin-auto-unpack-natives', config: {} }],
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createApplicationMenu() {
|
||||
label: `About ${app.getName()}`,
|
||||
click: async () => {
|
||||
await showMainWindow();
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.next();
|
||||
applicationMenuSubjects.openInSettingModal$.next('about');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
@@ -17,9 +17,9 @@ export const applicationMenuEvents = {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
openAboutPageInSettingModal: (fn: () => void) => {
|
||||
const sub =
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.subscribe(fn);
|
||||
// todo: properly define the active tab type
|
||||
openInSettingModal: (fn: (activeTab: string) => void) => {
|
||||
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ import { Subject } from 'rxjs';
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction$: new Subject<'page' | 'edgeless'>(),
|
||||
openJournal$: new Subject<void>(),
|
||||
openAboutPageInSettingModal$: new Subject<void>(),
|
||||
openInSettingModal$: new Subject<string>(),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { setupRecording } from './recording';
|
||||
import { setupRecordingFeature } from './recording/feature';
|
||||
import { setupTrayState } from './tray';
|
||||
import { registerUpdater } from './updater';
|
||||
import { launch } from './windows-manager/launcher';
|
||||
@@ -89,18 +89,10 @@ app
|
||||
.then(launch)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
.then(setupRecordingFeature)
|
||||
.then(setupTrayState)
|
||||
.catch(e => console.error('Failed create window:', e));
|
||||
|
||||
if (isDev) {
|
||||
app
|
||||
.whenReady()
|
||||
.then(setupRecording)
|
||||
.then(setupTrayState)
|
||||
.catch(e => {
|
||||
logger.error('Failed setup recording or tray state:', e);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.SENTRY_RELEASE) {
|
||||
// https://docs.sentry.io/platforms/javascript/guides/electron/
|
||||
Sentry.init({
|
||||
|
||||
685
packages/frontend/apps/electron/src/main/recording/feature.ts
Normal file
685
packages/frontend/apps/electron/src/main/recording/feature.ts
Normal file
@@ -0,0 +1,685 @@
|
||||
/* oxlint-disable no-var-requires */
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
import type { ShareableContent } from '@affine/native';
|
||||
import { app, systemPreferences } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
groupBy,
|
||||
interval,
|
||||
mergeMap,
|
||||
Subject,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
MeetingSettingsKey,
|
||||
MeetingSettingsSchema,
|
||||
} from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { recordingStateMachine } from './state-machine';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
Recording,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
|
||||
const MAX_DURATION_FOR_TRANSCRIPTION = 1.5 * 60 * 60 * 1000; // 1.5 hours
|
||||
|
||||
export const MeetingsSettingsState = {
|
||||
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
|
||||
map(v => MeetingSettingsSchema.parse(v ?? {})),
|
||||
shareReplay(1)
|
||||
),
|
||||
|
||||
get value() {
|
||||
return MeetingSettingsSchema.parse(
|
||||
globalStateStorage.get(MeetingSettingsKey) ?? {}
|
||||
);
|
||||
},
|
||||
|
||||
set value(value: MeetingSettingsSchema) {
|
||||
globalStateStorage.set(MeetingSettingsKey, value);
|
||||
},
|
||||
};
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
|
||||
// recordings are saved in the app data directory
|
||||
// may need a way to clean up old recordings
|
||||
export const SAVED_RECORDINGS_DIR = path.join(
|
||||
app.getPath('sessionData'),
|
||||
'recordings'
|
||||
);
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
|
||||
function cleanup() {
|
||||
shareableContent = null;
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeAppQuit(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
|
||||
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
// recording id -> recording
|
||||
// recordings will be saved in memory before consumed and created as an audio block to user's doc
|
||||
const recordings = new Map<number, Recording>();
|
||||
|
||||
// there should be only one active recording at a time
|
||||
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
processGroupId: processGroupId,
|
||||
apps: [], // leave it empty for now.
|
||||
name: groupProcess.name,
|
||||
bundleIdentifier: groupProcess.bundleIdentifier,
|
||||
// icon should be lazy loaded
|
||||
get icon() {
|
||||
try {
|
||||
return groupProcess.icon;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
// pipe applications$ to appGroups$
|
||||
function setupAppGroups() {
|
||||
subscribers.push(
|
||||
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
|
||||
const appGroups: AppGroupInfo[] = [];
|
||||
apps.forEach(app => {
|
||||
let appGroup = appGroups.find(
|
||||
group => group.processGroupId === app.processGroupId
|
||||
);
|
||||
|
||||
if (!appGroup) {
|
||||
appGroup = createAppGroup(app.processGroupId);
|
||||
if (appGroup) {
|
||||
appGroups.push(appGroup);
|
||||
}
|
||||
}
|
||||
if (appGroup) {
|
||||
appGroup.apps.push(app);
|
||||
}
|
||||
});
|
||||
|
||||
appGroups.forEach(appGroup => {
|
||||
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
|
||||
});
|
||||
|
||||
appGroups$.next(appGroups);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setupNewRunningAppGroup() {
|
||||
const appGroupRunningChanged$ = appGroups$.pipe(
|
||||
mergeMap(groups => groups),
|
||||
groupBy(group => group.processGroupId),
|
||||
mergeMap(groupStream$ =>
|
||||
groupStream$.pipe(
|
||||
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
appGroups$.value.forEach(group => {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
if (
|
||||
group.isRunning &&
|
||||
(!recordingStatus || recordingStatus.status === 'new')
|
||||
) {
|
||||
newRecording(group);
|
||||
}
|
||||
});
|
||||
|
||||
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
|
||||
// check if the app is running again
|
||||
if (appGroup.isRunning) {
|
||||
startRecording(appGroup);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
subscribers.push(
|
||||
appGroupRunningChanged$.subscribe(currentGroup => {
|
||||
logger.info(
|
||||
'appGroupRunningChanged',
|
||||
currentGroup.bundleIdentifier,
|
||||
currentGroup.isRunning
|
||||
);
|
||||
|
||||
if (MeetingsSettingsState.value.recordingMode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (currentGroup.isRunning) {
|
||||
// when the app is running and there is no active recording popup
|
||||
// we should show a new recording popup
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.status === 'new' ||
|
||||
recordingStatus.status === 'create-block-success' ||
|
||||
recordingStatus.status === 'create-block-failed'
|
||||
) {
|
||||
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
|
||||
newRecording(currentGroup);
|
||||
} else if (
|
||||
MeetingsSettingsState.value.recordingMode === 'auto-start'
|
||||
) {
|
||||
// there is a case that the watched app's running state changed rapidly
|
||||
// we will schedule the start recording to avoid that
|
||||
debounceStartRecording(currentGroup);
|
||||
} else {
|
||||
// do nothing, skip
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// when displaying in "new" state but the app is not running any more
|
||||
// we should remove the recording
|
||||
if (
|
||||
recordingStatus?.status === 'new' &&
|
||||
currentGroup.bundleIdentifier ===
|
||||
recordingStatus.appGroup?.bundleIdentifier
|
||||
) {
|
||||
removeRecording(recordingStatus.id);
|
||||
}
|
||||
|
||||
// if the recording is stopped and we are recording it,
|
||||
// we should stop the recording
|
||||
if (
|
||||
recordingStatus?.status === 'recording' &&
|
||||
recordingStatus.appGroup?.bundleIdentifier ===
|
||||
currentGroup.bundleIdentifier
|
||||
) {
|
||||
stopRecording(recordingStatus.id).catch(err => {
|
||||
logger.error('failed to stop recording', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createRecording(status: RecordingStatus) {
|
||||
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();
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.id !== status.id ||
|
||||
recordingStatus.status === 'paused'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error('failed to get audio samples', err);
|
||||
} else {
|
||||
// Writing raw Float32Array samples directly to file
|
||||
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||
file.write(Buffer.from(samples.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
const recording: Recording = {
|
||||
id: status.id,
|
||||
startTime: status.startTime,
|
||||
app: status.app,
|
||||
appGroup: status.appGroup,
|
||||
file,
|
||||
stream,
|
||||
};
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
const rawFilePath = String(recording.file.path);
|
||||
return {
|
||||
id,
|
||||
appGroup: recording.appGroup,
|
||||
app: recording.app,
|
||||
startTime: recording.startTime,
|
||||
filepath: rawFilePath,
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
};
|
||||
}
|
||||
|
||||
// recording popup status
|
||||
// new: recording is started, popup is shown
|
||||
// recording: recording is started, popup is shown
|
||||
// stopped: recording is stopped, popup showing processing status
|
||||
// create-block-success: recording is ready, show "open app" button
|
||||
// create-block-failed: recording is failed, show "failed to save" button
|
||||
// null: hide popup
|
||||
function setupRecordingListeners() {
|
||||
subscribers.push(
|
||||
recordingStatus$
|
||||
.pipe(distinctUntilChanged(shallowEqual))
|
||||
.subscribe(status => {
|
||||
const popup = popupManager.get('recording');
|
||||
|
||||
if (status && !popup.showing) {
|
||||
popup.show().catch(err => {
|
||||
logger.error('failed to show recording popup', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (status?.status === 'recording') {
|
||||
let recording = recordings.get(status.id);
|
||||
// create a recording if not exists
|
||||
if (!recording) {
|
||||
recording = createRecording(status);
|
||||
recordings.set(status.id, recording);
|
||||
}
|
||||
} else if (status?.status === 'stopped') {
|
||||
const recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
recording.stream.stop();
|
||||
}
|
||||
} else if (
|
||||
status?.status === 'create-block-success' ||
|
||||
status?.status === 'create-block-failed'
|
||||
) {
|
||||
// show the popup for 10s
|
||||
setTimeout(() => {
|
||||
// check again if current status is still ready
|
||||
if (
|
||||
(recordingStatus$.value?.status === 'create-block-success' ||
|
||||
recordingStatus$.value?.status === 'create-block-failed') &&
|
||||
recordingStatus$.value.id === status.id
|
||||
) {
|
||||
popup.hide().catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
}, 10_000);
|
||||
} else if (!status) {
|
||||
// status is removed, we should hide the popup
|
||||
popupManager
|
||||
.get('recording')
|
||||
.hide()
|
||||
.catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
try {
|
||||
return {
|
||||
rawInstance: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredApps = apps.filter(
|
||||
(v): v is TappableAppInfo =>
|
||||
v !== null &&
|
||||
!v.bundleIdentifier.startsWith('com.apple') &&
|
||||
v.processId !== process.pid
|
||||
);
|
||||
return filteredApps;
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
function setupMediaListeners() {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
ShareableContent.onApplicationListChanged(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
updateApplicationsPing$
|
||||
.pipe(distinctUntilChanged(), throttleTime(3000))
|
||||
.subscribe(() => {
|
||||
applications$.next(getAllApps());
|
||||
})
|
||||
);
|
||||
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
const _appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appStateSubscribers = _appStateSubscribers;
|
||||
return () => {
|
||||
_appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// will be called when the app is ready or when the user has enabled the recording feature in settings
|
||||
export function setupRecordingFeature() {
|
||||
if (!MeetingsSettingsState.value.enabled || !checkRecordingAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
if (!shareableContent) {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
}
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
// reset all states
|
||||
recordingStatus$.next(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('failed to setup recording feature', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableRecordingFeature() {
|
||||
recordingStatus$.next(null);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function normalizeAppGroupInfo(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): AppGroupInfo | undefined {
|
||||
return typeof appGroup === 'number'
|
||||
? appGroups$.value.find(group => group.processGroupId === appGroup)
|
||||
: appGroup;
|
||||
}
|
||||
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
if (!shareableContent) {
|
||||
return null; // likely called on unsupported platform
|
||||
}
|
||||
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'NEW_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
const state = recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
|
||||
if (state?.status === 'recording') {
|
||||
// set a timeout to stop the recording after MAX_DURATION_FOR_TRANSCRIPTION
|
||||
setTimeout(() => {
|
||||
stopRecording(state.id).catch(err => {
|
||||
logger.error('failed to stop recording', err);
|
||||
});
|
||||
}, MAX_DURATION_FOR_TRANSCRIPTION);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function pauseRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
|
||||
}
|
||||
|
||||
export function resumeRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.file.path) {
|
||||
logger.error(`Recording ${id} has no file path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id,
|
||||
filepath: String(recording.file.path),
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
});
|
||||
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const { file } = recording;
|
||||
file.end();
|
||||
|
||||
// Wait for file to finish writing
|
||||
await new Promise<void>(resolve => {
|
||||
file.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
|
||||
);
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// Update the status through the state machine
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
});
|
||||
|
||||
// bring up the window
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBlockCreationSuccess(id: number) {
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_SUCCESS',
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBlockCreationFailed(id: number, error?: Error) {
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_FAILED',
|
||||
id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeRecording(id: number) {
|
||||
recordings.delete(id);
|
||||
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
|
||||
}
|
||||
|
||||
export interface SerializedRecordingStatus {
|
||||
id: number;
|
||||
status: RecordingStatus['status'];
|
||||
appName?: string;
|
||||
// if there is no app group, it means the recording is for system audio
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
|
||||
export function serializeRecordingStatus(
|
||||
status: RecordingStatus
|
||||
): SerializedRecordingStatus {
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
appName: status.appGroup?.name,
|
||||
appGroupId: status.appGroup?.processGroupId,
|
||||
icon: status.appGroup?.icon,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export const getMacOSVersion = () => {
|
||||
try {
|
||||
const stdout = execSync('sw_vers -productVersion').toString();
|
||||
const [major, minor, patch] = stdout.trim().split('.').map(Number);
|
||||
return { major, minor, patch };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get MacOS version', error);
|
||||
return { major: 0, minor: 0, patch: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
// check if the system is MacOS and the version is >= 14.2
|
||||
export const checkRecordingAvailable = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
};
|
||||
|
||||
export const checkScreenRecordingPermission = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
return systemPreferences.getMediaAccessStatus('screen') === 'granted';
|
||||
};
|
||||
@@ -1,546 +1,32 @@
|
||||
// eslint-disable no-var-requires
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { ShareableContent } from '@affine/native';
|
||||
import { app } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
groupBy,
|
||||
interval,
|
||||
mergeMap,
|
||||
Subject,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { recordingStateMachine } from './state-machine';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
Recording,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
|
||||
// adhoc recordings are saved in the temp directory
|
||||
const SAVED_RECORDINGS_DIR = path.join(
|
||||
app.getPath('temp'),
|
||||
'affine-recordings'
|
||||
);
|
||||
|
||||
beforeAppQuit(() => {
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
|
||||
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
|
||||
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
// recording id -> recording
|
||||
// recordings will be saved in memory before consumed and created as an audio block to user's doc
|
||||
const recordings = new Map<number, Recording>();
|
||||
|
||||
// there should be only one active recording at a time
|
||||
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
processGroupId: processGroupId,
|
||||
apps: [], // leave it empty for now.
|
||||
name: groupProcess.name,
|
||||
bundleIdentifier: groupProcess.bundleIdentifier,
|
||||
// icon should be lazy loaded
|
||||
get icon() {
|
||||
try {
|
||||
return groupProcess.icon;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
// pipe applications$ to appGroups$
|
||||
function setupAppGroups() {
|
||||
subscribers.push(
|
||||
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
|
||||
const appGroups: AppGroupInfo[] = [];
|
||||
apps.forEach(app => {
|
||||
let appGroup = appGroups.find(
|
||||
group => group.processGroupId === app.processGroupId
|
||||
);
|
||||
|
||||
if (!appGroup) {
|
||||
appGroup = createAppGroup(app.processGroupId);
|
||||
if (appGroup) {
|
||||
appGroups.push(appGroup);
|
||||
}
|
||||
}
|
||||
if (appGroup) {
|
||||
appGroup.apps.push(app);
|
||||
}
|
||||
});
|
||||
|
||||
appGroups.forEach(appGroup => {
|
||||
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
|
||||
});
|
||||
|
||||
appGroups$.next(appGroups);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setupNewRunningAppGroup() {
|
||||
const appGroupRunningChanged$ = appGroups$.pipe(
|
||||
mergeMap(groups => groups),
|
||||
groupBy(group => group.processGroupId),
|
||||
mergeMap(groupStream$ =>
|
||||
groupStream$.pipe(
|
||||
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
appGroups$.value.forEach(group => {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
if (
|
||||
group.isRunning &&
|
||||
(!recordingStatus || recordingStatus.status === 'new')
|
||||
) {
|
||||
newRecording(group);
|
||||
}
|
||||
});
|
||||
|
||||
subscribers.push(
|
||||
appGroupRunningChanged$.subscribe(currentGroup => {
|
||||
logger.info(
|
||||
'appGroupRunningChanged',
|
||||
currentGroup.bundleIdentifier,
|
||||
currentGroup.isRunning
|
||||
);
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (currentGroup.isRunning) {
|
||||
// when the app is running and there is no active recording popup
|
||||
// we should show a new recording popup
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.status === 'new' ||
|
||||
recordingStatus.status === 'ready'
|
||||
) {
|
||||
newRecording(currentGroup);
|
||||
}
|
||||
} else {
|
||||
// when displaying in "new" state but the app is not running any more
|
||||
// we should remove the recording
|
||||
if (
|
||||
recordingStatus?.status === 'new' &&
|
||||
currentGroup.bundleIdentifier ===
|
||||
recordingStatus.appGroup?.bundleIdentifier
|
||||
) {
|
||||
removeRecording(recordingStatus.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createRecording(status: RecordingStatus) {
|
||||
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();
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.id !== status.id ||
|
||||
recordingStatus.status === 'paused'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error('failed to get audio samples', err);
|
||||
} else {
|
||||
// Writing raw Float32Array samples directly to file
|
||||
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||
file.write(Buffer.from(samples.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
const recording: Recording = {
|
||||
id: status.id,
|
||||
startTime: status.startTime,
|
||||
app: status.app,
|
||||
appGroup: status.appGroup,
|
||||
file,
|
||||
stream,
|
||||
};
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
const rawFilePath = String(recording.file.path);
|
||||
return {
|
||||
id,
|
||||
appGroup: recording.appGroup,
|
||||
app: recording.app,
|
||||
startTime: recording.startTime,
|
||||
filepath: rawFilePath,
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
};
|
||||
}
|
||||
|
||||
// recording popup status
|
||||
// new: recording is started, popup is shown
|
||||
// recording: recording is started, popup is shown
|
||||
// stopped: recording is stopped, popup showing processing status
|
||||
// ready: recording is ready, show "open app" button
|
||||
// null: hide popup
|
||||
function setupRecordingListeners() {
|
||||
subscribers.push(
|
||||
recordingStatus$
|
||||
.pipe(distinctUntilChanged(shallowEqual))
|
||||
.subscribe(status => {
|
||||
const popup = popupManager.get('recording');
|
||||
|
||||
if (status && !popup.showing) {
|
||||
popup.show().catch(err => {
|
||||
logger.error('failed to show recording popup', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (status?.status === 'recording') {
|
||||
let recording = recordings.get(status.id);
|
||||
// create a recording if not exists
|
||||
if (!recording) {
|
||||
recording = createRecording(status);
|
||||
recordings.set(status.id, recording);
|
||||
}
|
||||
} else if (status?.status === 'stopped') {
|
||||
const recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
recording.stream.stop();
|
||||
}
|
||||
} else if (status?.status === 'ready') {
|
||||
// show the popup for 10s
|
||||
setTimeout(() => {
|
||||
// check again if current status is still ready
|
||||
if (
|
||||
recordingStatus$.value?.status === 'ready' &&
|
||||
recordingStatus$.value.id === status.id
|
||||
) {
|
||||
popup.hide().catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
}, 10_000);
|
||||
} else if (!status) {
|
||||
// status is removed, we should hide the popup
|
||||
popupManager
|
||||
.get('recording')
|
||||
.hide()
|
||||
.catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
try {
|
||||
return {
|
||||
rawInstance: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredApps = apps.filter(
|
||||
(v): v is TappableAppInfo =>
|
||||
v !== null &&
|
||||
!v.bundleIdentifier.startsWith('com.apple') &&
|
||||
v.processId !== process.pid
|
||||
);
|
||||
return filteredApps;
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
function setupMediaListeners() {
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
ShareableContent.onApplicationListChanged(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
updateApplicationsPing$
|
||||
.pipe(distinctUntilChanged(), throttleTime(3000))
|
||||
.subscribe(() => {
|
||||
applications$.next(getAllApps());
|
||||
})
|
||||
);
|
||||
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
const _appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appStateSubscribers = _appStateSubscribers;
|
||||
return () => {
|
||||
_appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function setupRecording() {
|
||||
if (!isMacOS()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shareableContent) {
|
||||
try {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
} catch (error) {
|
||||
logger.error('failed to get shareable content', error);
|
||||
}
|
||||
}
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
}
|
||||
|
||||
function normalizeAppGroupInfo(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): AppGroupInfo | undefined {
|
||||
return typeof appGroup === 'number'
|
||||
? appGroups$.value.find(group => group.processGroupId === appGroup)
|
||||
: appGroup;
|
||||
}
|
||||
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
if (!shareableContent) {
|
||||
return null; // likely called on unsupported platform
|
||||
}
|
||||
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'NEW_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function pauseRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
|
||||
}
|
||||
|
||||
export function resumeRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.file.path) {
|
||||
logger.error(`Recording ${id} has no file path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id,
|
||||
filepath: String(recording.file.path),
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
});
|
||||
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const { file } = recording;
|
||||
file.end();
|
||||
|
||||
// Wait for file to finish writing
|
||||
await new Promise<void>(resolve => {
|
||||
file.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
|
||||
);
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// Update the status through the state machine
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
});
|
||||
|
||||
// bring up the window
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecording(id: number) {
|
||||
recordings.delete(id);
|
||||
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
|
||||
}
|
||||
|
||||
export interface SerializedRecordingStatus {
|
||||
id: number;
|
||||
status: RecordingStatus['status'];
|
||||
appName?: string;
|
||||
// if there is no app group, it means the recording is for system audio
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
|
||||
function serializeRecordingStatus(
|
||||
status: RecordingStatus
|
||||
): SerializedRecordingStatus {
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
appName: status.appGroup?.name,
|
||||
appGroupId: status.appGroup?.processGroupId,
|
||||
icon: status.appGroup?.icon,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
};
|
||||
}
|
||||
import {
|
||||
checkRecordingAvailable,
|
||||
checkScreenRecordingPermission,
|
||||
disableRecordingFeature,
|
||||
getRecording,
|
||||
handleBlockCreationFailed,
|
||||
handleBlockCreationSuccess,
|
||||
pauseRecording,
|
||||
readyRecording,
|
||||
recordingStatus$,
|
||||
removeRecording,
|
||||
SAVED_RECORDINGS_DIR,
|
||||
type SerializedRecordingStatus,
|
||||
serializeRecordingStatus,
|
||||
setupRecordingFeature,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} from './feature';
|
||||
import type { AppGroupInfo } from './types';
|
||||
|
||||
export const recordingHandlers = {
|
||||
getRecording: async (_, id: number) => {
|
||||
@@ -565,9 +51,47 @@ export const recordingHandlers = {
|
||||
readyRecording: async (_, id: number, buffer: Uint8Array) => {
|
||||
return readyRecording(id, Buffer.from(buffer));
|
||||
},
|
||||
handleBlockCreationSuccess: async (_, id: number) => {
|
||||
return handleBlockCreationSuccess(id);
|
||||
},
|
||||
handleBlockCreationFailed: async (_, id: number, error?: Error) => {
|
||||
return handleBlockCreationFailed(id, error);
|
||||
},
|
||||
removeRecording: async (_, id: number) => {
|
||||
return removeRecording(id);
|
||||
},
|
||||
checkRecordingAvailable: async () => {
|
||||
return checkRecordingAvailable();
|
||||
},
|
||||
setupRecordingFeature: async () => {
|
||||
return setupRecordingFeature();
|
||||
},
|
||||
disableRecordingFeature: async () => {
|
||||
return disableRecordingFeature();
|
||||
},
|
||||
checkScreenRecordingPermission: async () => {
|
||||
return checkScreenRecordingPermission();
|
||||
},
|
||||
showScreenRecordingPermissionSetting: async () => {
|
||||
if (isMacOS()) {
|
||||
return shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
|
||||
);
|
||||
}
|
||||
// this only available on MacOS
|
||||
return false;
|
||||
},
|
||||
showSavedRecordings: async (_, subpath?: string) => {
|
||||
const normalizedDir = path.normalize(
|
||||
path.join(SAVED_RECORDINGS_DIR, subpath ?? '')
|
||||
);
|
||||
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR);
|
||||
|
||||
if (!normalizedDir.startsWith(normalizedBase)) {
|
||||
throw new Error('Invalid directory');
|
||||
}
|
||||
return shell.showItemInFolder(normalizedDir);
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export const recordingEvents = {
|
||||
|
||||
@@ -4,17 +4,6 @@ import { shallowEqual } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import type { AppGroupInfo, RecordingStatus } from './types';
|
||||
|
||||
/**
|
||||
* Possible states for a recording
|
||||
*/
|
||||
export type RecordingState =
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'inactive';
|
||||
|
||||
/**
|
||||
* Recording state machine events
|
||||
*/
|
||||
@@ -35,6 +24,15 @@ export type RecordingEvent =
|
||||
id: number;
|
||||
filepath: string;
|
||||
}
|
||||
| {
|
||||
type: 'CREATE_BLOCK_FAILED';
|
||||
id: number;
|
||||
error?: Error;
|
||||
}
|
||||
| {
|
||||
type: 'CREATE_BLOCK_SUCCESS';
|
||||
id: number;
|
||||
}
|
||||
| { type: 'REMOVE_RECORDING'; id: number };
|
||||
|
||||
/**
|
||||
@@ -93,6 +91,12 @@ export class RecordingStateMachine {
|
||||
case 'SAVE_RECORDING':
|
||||
newStatus = this.handleSaveRecording(event.id, event.filepath);
|
||||
break;
|
||||
case 'CREATE_BLOCK_SUCCESS':
|
||||
newStatus = this.handleCreateBlockSuccess(event.id);
|
||||
break;
|
||||
case 'CREATE_BLOCK_FAILED':
|
||||
newStatus = this.handleCreateBlockFailed(event.id, event.error);
|
||||
break;
|
||||
case 'REMOVE_RECORDING':
|
||||
this.handleRemoveRecording(event.id);
|
||||
newStatus = currentStatus?.id === event.id ? null : currentStatus;
|
||||
@@ -255,6 +259,47 @@ export class RecordingStateMachine {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the CREATE_BLOCK_SUCCESS event
|
||||
*/
|
||||
private handleCreateBlockSuccess(id: number): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
|
||||
if (!currentStatus || currentStatus.id !== id) {
|
||||
logger.error(`Recording ${id} not found for create-block-success`);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentStatus,
|
||||
status: 'create-block-success',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the CREATE_BLOCK_FAILED event
|
||||
*/
|
||||
private handleCreateBlockFailed(
|
||||
id: number,
|
||||
error?: Error
|
||||
): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
|
||||
if (!currentStatus || currentStatus.id !== id) {
|
||||
logger.error(`Recording ${id} not found for create-block-failed`);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(`Recording ${id} create block failed:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
...currentStatus,
|
||||
status: 'create-block-failed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the REMOVE_RECORDING event
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,16 @@ export interface RecordingStatus {
|
||||
// paused: the recording is paused
|
||||
// stopped: the recording is stopped (processing audio file for use in the editor)
|
||||
// ready: the recording is ready to be used
|
||||
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
|
||||
// create-block-success: the recording is successfully created as a block
|
||||
// create-block-failed: creating block failed
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
app?: TappableAppInfo;
|
||||
appGroup?: AppGroupInfo;
|
||||
startTime: number; // 0 means not started yet
|
||||
|
||||
@@ -53,3 +53,21 @@ export const SpellCheckStateSchema = z.object({
|
||||
export const SpellCheckStateKey = 'spellCheckState' as const;
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
|
||||
|
||||
export const MeetingSettingsKey = 'meetingSettings' as const;
|
||||
export const MeetingSettingsSchema = z.object({
|
||||
// global meeting feature control
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
// when recording is saved, where to create the recording block
|
||||
recordingSavingMode: z.enum(['new-doc', 'journal-today']).default('new-doc'),
|
||||
|
||||
// whether to enable auto transcription for new meeting recordings
|
||||
autoTranscription: z.boolean().default(true),
|
||||
|
||||
// recording reactions to new meeting events
|
||||
recordingMode: z.enum(['none', 'prompt', 'auto-start']).default('prompt'),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export type MeetingSettingsSchema = z.infer<typeof MeetingSettingsSchema>;
|
||||
|
||||
@@ -14,11 +14,14 @@ import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
appGroups$,
|
||||
checkRecordingAvailable,
|
||||
checkScreenRecordingPermission,
|
||||
MeetingsSettingsState,
|
||||
recordingStatus$,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
updateApplicationsPing$,
|
||||
} from '../recording';
|
||||
} from '../recording/feature';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { icons } from './icons';
|
||||
|
||||
@@ -125,30 +128,37 @@ class TrayState {
|
||||
};
|
||||
}
|
||||
|
||||
getRecordingMenuProvider(): TrayMenuProvider {
|
||||
const appGroups = appGroups$.value;
|
||||
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
getRecordingMenuProvider(): TrayMenuProvider | null {
|
||||
if (
|
||||
!recordingStatus ||
|
||||
(recordingStatus?.status !== 'paused' &&
|
||||
recordingStatus?.status !== 'recording')
|
||||
!checkRecordingAvailable() ||
|
||||
!checkScreenRecordingPermission() ||
|
||||
!MeetingsSettingsState.value.enabled
|
||||
) {
|
||||
const appMenuItems = runningAppGroups.map(appGroup => ({
|
||||
label: appGroup.name,
|
||||
icon: appGroup.icon || undefined,
|
||||
click: () => {
|
||||
logger.info(
|
||||
`User action: Start Recording Meeting (${appGroup.name})`
|
||||
);
|
||||
startRecording(appGroup);
|
||||
},
|
||||
}));
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig: () => [
|
||||
return null;
|
||||
}
|
||||
|
||||
const getConfig = () => {
|
||||
const appGroups = appGroups$.value;
|
||||
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (
|
||||
!recordingStatus ||
|
||||
(recordingStatus?.status !== 'paused' &&
|
||||
recordingStatus?.status !== 'recording')
|
||||
) {
|
||||
const appMenuItems = runningAppGroups.map(appGroup => ({
|
||||
label: appGroup.name,
|
||||
icon: appGroup.icon || undefined,
|
||||
click: () => {
|
||||
logger.info(
|
||||
`User action: Start Recording Meeting (${appGroup.name})`
|
||||
);
|
||||
startRecording(appGroup);
|
||||
},
|
||||
}));
|
||||
return [
|
||||
{
|
||||
label: 'Start Recording Meeting',
|
||||
icon: icons.record,
|
||||
@@ -167,18 +177,22 @@ class TrayState {
|
||||
],
|
||||
},
|
||||
...appMenuItems,
|
||||
],
|
||||
};
|
||||
}
|
||||
{
|
||||
label: `Meetings Settings...`,
|
||||
click: async () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next('meetings');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const recordingLabel = recordingStatus.appGroup?.name
|
||||
? `Recording (${recordingStatus.appGroup?.name})`
|
||||
: 'Recording';
|
||||
const recordingLabel = recordingStatus.appGroup?.name
|
||||
? `Recording (${recordingStatus.appGroup?.name})`
|
||||
: 'Recording';
|
||||
|
||||
// recording is either started or paused
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig: () => [
|
||||
// recording is either started or paused
|
||||
return [
|
||||
{
|
||||
label: recordingLabel,
|
||||
icon: icons.recording,
|
||||
@@ -193,7 +207,12 @@ class TrayState {
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +233,13 @@ class TrayState {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: `About ${app.getName()}`,
|
||||
click: () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next('about');
|
||||
},
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
label: 'Quit AFFiNE Completely...',
|
||||
@@ -267,7 +293,7 @@ class TrayState {
|
||||
|
||||
const providers = [
|
||||
this.getPrimaryMenuProvider(),
|
||||
isMacOS() ? this.getRecordingMenuProvider() : null,
|
||||
this.getRecordingMenuProvider(),
|
||||
this.getSecondaryMenuProvider(),
|
||||
].filter(p => p !== null);
|
||||
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
"@radix-ui/react-visually-hidden": "^1.1.1",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@vanilla-extract/dynamic": "^2.1.2",
|
||||
"bytes": "^3.1.2",
|
||||
"check-password-strength": "^3.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"foxact": "^0.2.45",
|
||||
"jotai": "^2.10.3",
|
||||
"lit": "^3.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -77,6 +79,7 @@
|
||||
"@storybook/react-vite": "^8.4.7",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
|
||||
@@ -29,6 +29,10 @@ export const wrapper = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const wrapperDisabled = style({
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
globalStyle(`${wrapper} .title`, {
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { wrapper } from './share.css';
|
||||
import { wrapper, wrapperDisabled } from './share.css';
|
||||
|
||||
interface SettingWrapperProps {
|
||||
title?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingWrapper = ({
|
||||
title,
|
||||
children,
|
||||
disabled,
|
||||
}: PropsWithChildren<SettingWrapperProps>) => {
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<div className={clsx(wrapper, disabled && wrapperDisabled)}>
|
||||
{title ? <div className="title">{title}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './hooks';
|
||||
export * from './lit-react';
|
||||
export * from './styles';
|
||||
export * from './ui/audio-player';
|
||||
export * from './ui/avatar';
|
||||
export * from './ui/button';
|
||||
export * from './ui/checkbox';
|
||||
@@ -14,9 +15,7 @@ export * from './ui/error-message';
|
||||
export * from './ui/input';
|
||||
export * from './ui/layout';
|
||||
export * from './ui/loading';
|
||||
export * from './ui/lottie/collections-icon';
|
||||
export * from './ui/lottie/delete-icon';
|
||||
export * from './ui/lottie/folder-icon';
|
||||
export * from './ui/lottie';
|
||||
export * from './ui/masonry';
|
||||
export * from './ui/menu';
|
||||
export * from './ui/modal';
|
||||
|
||||
@@ -4,8 +4,9 @@ import React, { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { createComponent } from './create-component';
|
||||
|
||||
export
|
||||
@customElement('affine-lit-template-wrapper')
|
||||
export class LitTemplateWrapper extends LitElement {
|
||||
class LitTemplateWrapper extends LitElement {
|
||||
static override get properties() {
|
||||
return {
|
||||
template: { type: Object },
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AudioPlayer, MiniAudioPlayer } from './audio-player';
|
||||
|
||||
const AudioWrapper = () => {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [waveform, setWaveform] = useState<number[] | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<
|
||||
'idle' | 'playing' | 'paused' | 'stopped'
|
||||
>('idle');
|
||||
const [seekTime, setSeekTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Generate waveform data from audio file
|
||||
const generateWaveform = async (audioBuffer: AudioBuffer) => {
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samples = 1000;
|
||||
const blockSize = Math.floor(channelData.length / samples);
|
||||
const waveformData = [];
|
||||
|
||||
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]);
|
||||
}
|
||||
waveformData.push(sum / blockSize);
|
||||
}
|
||||
|
||||
// Normalize waveform data
|
||||
const max = Math.max(...waveformData);
|
||||
return waveformData.map(val => val / max);
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setAudioFile(file);
|
||||
setPlaybackState('idle');
|
||||
setSeekTime(0);
|
||||
setDuration(0);
|
||||
setWaveform(null);
|
||||
|
||||
// Revoke previous URL if exists
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
// Create new URL for the audio file
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
audioUrlRef.current = fileUrl;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const waveformData = await generateWaveform(audioBuffer);
|
||||
setWaveform(waveformData);
|
||||
} catch (error) {
|
||||
console.error('Error processing audio file:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handlePlay = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
const playPromise = audioRef.current.play();
|
||||
|
||||
// Handle play promise to catch any errors
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setPlaybackState('playing');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
setPlaybackState('paused');
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (audioRef.current) {
|
||||
// Ensure time is within valid range
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(time, audioRef.current.duration)
|
||||
);
|
||||
audioRef.current.currentTime = clampedTime;
|
||||
if (playbackState === 'stopped') {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audioFile) return;
|
||||
|
||||
const updateTime = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const updateDuration = () => {
|
||||
if (!isNaN(audio.duration) && isFinite(audio.duration)) {
|
||||
setDuration(audio.duration);
|
||||
setPlaybackState('paused');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle direct interaction with audio element controls
|
||||
const handleNativeTimeUpdate = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handleNativePlay = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handleNativePause = () => {
|
||||
if (audio.currentTime >= audio.duration - 0.1) {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handlePaused = () => {
|
||||
if (audio.currentTime === 0) {
|
||||
setPlaybackState('stopped');
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Audio playback error');
|
||||
setPlaybackState('stopped');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Add all event listeners
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.addEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.addEventListener('play', handleNativePlay);
|
||||
audio.addEventListener('pause', handleNativePause);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('durationchange', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('playing', handlePlaying);
|
||||
audio.addEventListener('pause', handlePaused);
|
||||
audio.addEventListener('error', handleError);
|
||||
audio.addEventListener('waiting', handleWaiting);
|
||||
audio.addEventListener('canplay', handleCanPlay);
|
||||
|
||||
return () => {
|
||||
// Remove all event listeners
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('play', handleNativePlay);
|
||||
audio.removeEventListener('pause', handleNativePause);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('durationchange', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('playing', handlePlaying);
|
||||
audio.removeEventListener('pause', handlePaused);
|
||||
audio.removeEventListener('error', handleError);
|
||||
audio.removeEventListener('waiting', handleWaiting);
|
||||
audio.removeEventListener('canplay', handleCanPlay);
|
||||
};
|
||||
}, [audioFile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
border: '2px dashed #ccc',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
gap: '20px',
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
>
|
||||
{!audioFile ? (
|
||||
<>
|
||||
<div>Drag & drop an audio file here, or</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrlRef.current || ''}
|
||||
preload="metadata"
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '600px' }}
|
||||
/>
|
||||
<MiniAudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
<AudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AudioWrapper> = {
|
||||
title: 'UI/AudioPlayer',
|
||||
component: AudioWrapper,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AudioWrapper>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import {
|
||||
AddThirtySecondIcon,
|
||||
CloseIcon,
|
||||
@@ -9,9 +8,10 @@ import bytes from 'bytes';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { IconButton } from '../button';
|
||||
import { AnimatedPlayIcon } from '../lottie';
|
||||
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 => {
|
||||
2
packages/frontend/component/src/ui/audio-player/index.ts
Normal file
2
packages/frontend/component/src/ui/audio-player/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './audio-player';
|
||||
export * from './audio-waveform';
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatedPlayIcon } from './animated-play-icon';
|
||||
|
||||
export default {
|
||||
title: 'UI/Audio Player/Animated Play Icon',
|
||||
component: AnimatedPlayIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'An animated icon that transitions between play, pause, and loading states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AnimatedPlayIcon>;
|
||||
|
||||
const Template: StoryFn<typeof AnimatedPlayIcon> = args => (
|
||||
<AnimatedPlayIcon {...args} />
|
||||
);
|
||||
|
||||
export const Play = Template.bind({});
|
||||
Play.args = {
|
||||
state: 'play',
|
||||
};
|
||||
|
||||
export const Pause = Template.bind({});
|
||||
Pause.args = {
|
||||
state: 'pause',
|
||||
};
|
||||
|
||||
export const Loading = Template.bind({});
|
||||
Loading.args = {
|
||||
state: 'loading',
|
||||
};
|
||||
|
||||
export const WithStateToggle: StoryFn<typeof AnimatedPlayIcon> = () => {
|
||||
const [state, setState] = useState<'play' | 'pause' | 'loading'>('play');
|
||||
|
||||
const cycleState = () => {
|
||||
setState(current => {
|
||||
switch (current) {
|
||||
case 'play':
|
||||
return 'pause';
|
||||
case 'pause':
|
||||
return 'play';
|
||||
case 'loading':
|
||||
return 'play';
|
||||
default:
|
||||
return 'play';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<AnimatedPlayIcon state={state} />
|
||||
<button onClick={cycleState}>Toggle State (Current: {state})</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import clsx from 'clsx';
|
||||
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
||||
import type { LottieRef } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { Loading } from '../loading';
|
||||
import playandpause from './playandpause.json';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface AnimatedPlayIconProps {
|
||||
state: 'play' | 'pause' | 'loading';
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PlayAndPauseIcon = ({
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
}: {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
state: 'play' | 'pause';
|
||||
}) => {
|
||||
const lottieRef: LottieRef = useRef(null);
|
||||
const prevStateRef = useRef(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lottieRef.current) return;
|
||||
const lottie = lottieRef.current;
|
||||
lottie.setSpeed(2);
|
||||
|
||||
// Only animate if state actually changed
|
||||
if (prevStateRef.current !== state) {
|
||||
if (state === 'play') {
|
||||
// Animate from pause to play
|
||||
lottie.playSegments([120, 160], true);
|
||||
} else {
|
||||
// Animate from play to pause
|
||||
lottie.playSegments([60, 100], true);
|
||||
}
|
||||
prevStateRef.current = state;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Lottie
|
||||
onClick={onClick}
|
||||
lottieRef={lottieRef}
|
||||
className={clsx(styles.root, className)}
|
||||
animationData={playandpause}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnimatedPlayIcon = ({
|
||||
state: _state,
|
||||
className,
|
||||
onClick,
|
||||
}: AnimatedPlayIconProps) => {
|
||||
const state = useDebouncedValue(_state, 25);
|
||||
if (state === 'loading') {
|
||||
return <Loading size={40} />;
|
||||
}
|
||||
return (
|
||||
<PlayAndPauseIcon state={state} onClick={onClick} className={className} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatedTranscribeIcon } from './animated-transcribe-icon';
|
||||
|
||||
export default {
|
||||
title: 'UI/Audio Player/Animated Transcribe Icon',
|
||||
component: AnimatedTranscribeIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'An animated icon that shows transcription state with smooth transitions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AnimatedTranscribeIcon>;
|
||||
|
||||
const Template: StoryFn<typeof AnimatedTranscribeIcon> = args => (
|
||||
<AnimatedTranscribeIcon {...args} />
|
||||
);
|
||||
|
||||
export const Idle = Template.bind({});
|
||||
Idle.args = {
|
||||
state: 'idle',
|
||||
};
|
||||
|
||||
export const Transcribing = Template.bind({});
|
||||
Transcribing.args = {
|
||||
state: 'transcribing',
|
||||
};
|
||||
|
||||
export const WithStateToggle: StoryFn<typeof AnimatedTranscribeIcon> = () => {
|
||||
const [state, setState] = useState<'idle' | 'transcribing'>('idle');
|
||||
|
||||
const toggleState = () => {
|
||||
setState(current => (current === 'idle' ? 'transcribing' : 'idle'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<AnimatedTranscribeIcon state={state} />
|
||||
<button onClick={toggleState}>Toggle State (Current: {state})</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
packages/frontend/component/src/ui/lottie/index.ts
Normal file
5
packages/frontend/component/src/ui/lottie/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './animated-play-icon';
|
||||
export * from './animated-transcribe-icon';
|
||||
export * from './collections-icon';
|
||||
export * from './delete-icon';
|
||||
export * from './folder-icon';
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 60,
|
||||
"op": 103,
|
||||
"ip": 0,
|
||||
"op": 161,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "pause to play",
|
||||
@@ -12,8 +12,160 @@
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 3,
|
||||
"nm": "Void::Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [21.125, 20, 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,
|
||||
"ef": [
|
||||
{
|
||||
"ty": 5,
|
||||
"nm": "Void",
|
||||
"np": 19,
|
||||
"mn": "Pseudo/250958",
|
||||
"ix": 1,
|
||||
"en": 1,
|
||||
"ef": [
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Width",
|
||||
"mn": "Pseudo/250958-0001",
|
||||
"ix": 1,
|
||||
"v": { "a": 0, "k": 100, "ix": 1 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Height",
|
||||
"mn": "Pseudo/250958-0002",
|
||||
"ix": 2,
|
||||
"v": { "a": 0, "k": 100, "ix": 2 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Offset X",
|
||||
"mn": "Pseudo/250958-0003",
|
||||
"ix": 3,
|
||||
"v": { "a": 0, "k": 0, "ix": 3 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Offset Y",
|
||||
"mn": "Pseudo/250958-0004",
|
||||
"ix": 4,
|
||||
"v": { "a": 0, "k": 0, "ix": 4 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Roundness",
|
||||
"mn": "Pseudo/250958-0005",
|
||||
"ix": 5,
|
||||
"v": { "a": 0, "k": 0, "ix": 5 }
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "About",
|
||||
"mn": "Pseudo/250958-0006",
|
||||
"ix": 6,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Plague of null layers.",
|
||||
"mn": "Pseudo/250958-0007",
|
||||
"ix": 7,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0008",
|
||||
"ix": 8,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Following projects",
|
||||
"mn": "Pseudo/250958-0009",
|
||||
"ix": 9,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0010",
|
||||
"ix": 10,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "through time.",
|
||||
"mn": "Pseudo/250958-0011",
|
||||
"ix": 11,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0012",
|
||||
"ix": 12,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Be free of the past.",
|
||||
"mn": "Pseudo/250958-0013",
|
||||
"ix": 13,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0014",
|
||||
"ix": 14,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Copyright 2023 Battle Axe Inc",
|
||||
"mn": "Pseudo/250958-0015",
|
||||
"ix": 15,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0016",
|
||||
"ix": 16,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0017",
|
||||
"ix": 17,
|
||||
"v": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"parent": 1,
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -42,7 +194,7 @@
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"p": { "a": 0, "k": [0, 0, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
@@ -54,7 +206,7 @@
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
@@ -67,11 +219,11 @@
|
||||
},
|
||||
{
|
||||
"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] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [100, 100, 100] }
|
||||
{ "t": 160, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
@@ -200,7 +352,7 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "Union",
|
||||
"sr": 1,
|
||||
@@ -244,14 +396,14 @@
|
||||
},
|
||||
{
|
||||
"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] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 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,
|
||||
"t": 100,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
@@ -406,9 +558,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 3",
|
||||
"nm": "wave",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -516,9 +668,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"nm": "wave",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -626,9 +778,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ind": 6,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"nm": "circle",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
@@ -1,15 +1,61 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
const magicColor = `rgb(119,117,125)`;
|
||||
globalStyle(`${root} path[stroke="${magicColor}"]`, {
|
||||
stroke: 'currentColor',
|
||||
});
|
||||
globalStyle(`${root} path[fill="${magicColor}"]`, {
|
||||
fill: 'currentColor',
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
// replace primary colors to cssVarV2('icon/primary')
|
||||
const iconPrimaryColors = [
|
||||
// legacy "--affine-icon-color"
|
||||
'rgb(119,117,125)',
|
||||
// --affine-v2-icon-primary
|
||||
'rgb(122,122,122)',
|
||||
];
|
||||
|
||||
// todo: may need to replace secondary colors & background colors as well?
|
||||
|
||||
const backgroundPrimaryColors = [
|
||||
// --affine-v2-background-primary
|
||||
'rgb(255,255,255)',
|
||||
'#ffffff',
|
||||
];
|
||||
|
||||
const backgroundSecondaryColors = [
|
||||
// --affine-v2-background-secondary
|
||||
'rgb(245,245,245)',
|
||||
];
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: cssVarV2('icon/primary'),
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`,
|
||||
{
|
||||
stroke: cssVarV2('icon/primary'),
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: cssVarV2('layer/background/secondary'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swr": "2.3.3",
|
||||
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
|
||||
"webm-muxer": "^5.1.0",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -13,8 +13,21 @@ export class LitTranscriptionBlock extends BlockComponent<TranscriptionBlockMode
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
get lastCalloutBlock() {
|
||||
for (const child of this.model.children.toReversed()) {
|
||||
if (child.flavour === 'affine:callout') {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.std.host.renderChildren(this.model);
|
||||
return this.std.host.renderChildren(this.model, model => {
|
||||
// if model is the last transcription block, we should render it
|
||||
return model === this.lastCalloutBlock;
|
||||
});
|
||||
}
|
||||
|
||||
@property({ type: String, attribute: 'data-block-id' })
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Button, Tooltip, useConfirmModal } from '@affine/component';
|
||||
import { AudioPlayer } from '@affine/core/components/audio-player';
|
||||
import { AnimatedTranscribeIcon } from '@affine/core/components/audio-player/lottie/animated-transcribe-icon';
|
||||
import { useSeekTime } from '@affine/core/components/audio-player/use-seek-time';
|
||||
import {
|
||||
AnimatedTranscribeIcon,
|
||||
Button,
|
||||
Tooltip,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { AudioPlayer } from '@affine/component/ui/audio-player';
|
||||
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useSeekTime } from '@affine/core/components/hooks/use-seek-time';
|
||||
import { CurrentServerScopeProvider } from '@affine/core/components/providers/current-server-scope';
|
||||
import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block';
|
||||
import { useAttachmentMediaBlock } from '@affine/core/modules/media/views/use-attachment-media';
|
||||
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { AttachmentViewerProps } from '../types';
|
||||
import * as styles from './audio-block.css';
|
||||
@@ -177,6 +182,31 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const AudioBlockEmbedded = (props: AttachmentViewerProps) => {
|
||||
const audioAttachmentBlock = useAttachmentMediaBlock(props.model);
|
||||
const transcriptionBlock = useLiveData(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './audio-player';
|
||||
@@ -1,60 +0,0 @@
|
||||
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 pausetoplay from './pausetoplay.json';
|
||||
import playtopause from './playtopause.json';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
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} />;
|
||||
};
|
||||
@@ -1,715 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
// replace primary colors to cssVarV2('icon/primary')
|
||||
const iconPrimaryColors = [
|
||||
// legacy "--affine-icon-color"
|
||||
'rgb(119,117,125)',
|
||||
// --affine-v2-icon-primary
|
||||
'rgb(122,122,122)',
|
||||
];
|
||||
|
||||
// todo: may need to replace secondary colors & background colors as well?
|
||||
|
||||
const backgroundPrimaryColors = [
|
||||
// --affine-v2-background-primary
|
||||
'rgb(255,255,255)',
|
||||
'#ffffff',
|
||||
];
|
||||
|
||||
const backgroundSecondaryColors = [
|
||||
// --affine-v2-background-secondary
|
||||
'rgb(245,245,245)',
|
||||
];
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: cssVarV2('icon/primary'),
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`,
|
||||
{
|
||||
stroke: cssVarV2('icon/primary'),
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: cssVarV2('layer/background/secondary'),
|
||||
}
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MiniAudioPlayer } from '@affine/component/ui/audio-player';
|
||||
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';
|
||||
@@ -5,8 +6,7 @@ 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 { useSeekTime } from '../hooks/use-seek-time';
|
||||
import * as styles from './sidebar-audio-player.css';
|
||||
|
||||
export const SidebarAudioPlayer = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderIcon,
|
||||
InformationIcon,
|
||||
KeyboardIcon,
|
||||
MeetingIcon,
|
||||
NotificationIcon,
|
||||
PenIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
@@ -23,6 +24,7 @@ import { BillingSettings } from './billing';
|
||||
import { EditorSettings } from './editor';
|
||||
import { ExperimentalFeatures } from './experimental-features';
|
||||
import { PaymentIcon, UpgradeIcon } from './icons';
|
||||
import { MeetingsSettings } from './meetings';
|
||||
import { NotificationSettings } from './notifications';
|
||||
import { AFFiNEPricingPlans } from './plans';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
@@ -46,6 +48,9 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
const enableEditorSettings = useLiveData(
|
||||
featureFlagService.flags.enable_editor_settings.$
|
||||
);
|
||||
const enableMeetings = useLiveData(
|
||||
featureFlagService.flags.enable_meetings.$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
userFeatureService.userFeature.revalidate();
|
||||
@@ -83,6 +88,15 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMeetings) {
|
||||
settings.push({
|
||||
key: 'meetings',
|
||||
title: t['com.affine.settings.meetings'](),
|
||||
icon: <MeetingIcon />,
|
||||
testId: 'meetings-panel-trigger',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPaymentFeature) {
|
||||
settings.splice(4, 0, {
|
||||
key: 'plans',
|
||||
@@ -147,6 +161,8 @@ export const GeneralSetting = ({
|
||||
return <EditorSettings />;
|
||||
case 'appearance':
|
||||
return <AppearanceSettings />;
|
||||
case 'meetings':
|
||||
return <MeetingsSettings />;
|
||||
case 'about':
|
||||
return <AboutAffine />;
|
||||
case 'plans':
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Switch,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
|
||||
import type { MeetingSettingsSchema } from '@affine/electron/main/shared-state-schema';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ArrowRightSmallIcon,
|
||||
DoneIcon,
|
||||
InformationFillDuotoneIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const RecordingModes: MeetingSettingsSchema['recordingMode'][] = [
|
||||
'prompt',
|
||||
'auto-start',
|
||||
'none',
|
||||
];
|
||||
|
||||
const RecordingModeMenu = () => {
|
||||
const meetingSettingsService = useService(MeetingSettingsService);
|
||||
const settings = useLiveData(meetingSettingsService.settings$);
|
||||
const t = useI18n();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return RecordingModes.map(mode => ({
|
||||
label: t[`com.affine.settings.meetings.record.recording-mode.${mode}`](),
|
||||
value: mode,
|
||||
}));
|
||||
}, [t]);
|
||||
|
||||
const currentMode = settings.recordingMode;
|
||||
|
||||
const handleRecordingModeChange = useCallback(
|
||||
(mode: MeetingSettingsSchema['recordingMode']) => {
|
||||
meetingSettingsService.setRecordingMode(mode);
|
||||
},
|
||||
[meetingSettingsService]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={options.map(option => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
onSelect={() => handleRecordingModeChange(option.value)}
|
||||
data-selected={currentMode === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<MenuTrigger style={{ fontWeight: 600, width: '250px' }} block={true}>
|
||||
{options.find(option => option.value === currentMode)?.label}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const MeetingsSettings = () => {
|
||||
const t = useI18n();
|
||||
const meetingSettingsService = useService(MeetingSettingsService);
|
||||
const settings = useLiveData(meetingSettingsService.settings$);
|
||||
const [recordingFeatureAvailable, setRecordingFeatureAvailable] =
|
||||
useState(false);
|
||||
|
||||
const [screenRecordingPermission, setScreenRecordingPermission] =
|
||||
useState(false);
|
||||
|
||||
const confirmModal = useConfirmModal();
|
||||
|
||||
useEffect(() => {
|
||||
meetingSettingsService
|
||||
.isRecordingFeatureAvailable()
|
||||
.then(available => {
|
||||
setRecordingFeatureAvailable(available ?? false);
|
||||
})
|
||||
.catch(() => {
|
||||
setRecordingFeatureAvailable(false);
|
||||
});
|
||||
meetingSettingsService
|
||||
.checkScreenRecordingPermission()
|
||||
.then(permission => {
|
||||
setScreenRecordingPermission(permission ?? false);
|
||||
})
|
||||
.catch(err => console.log(err));
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
const handleEnabledChange = useAsyncCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await meetingSettingsService.setEnabled(checked);
|
||||
} catch {
|
||||
confirmModal.openConfirmModal({
|
||||
title:
|
||||
t['com.affine.settings.meetings.record.permission-modal.title'](),
|
||||
description:
|
||||
t[
|
||||
'com.affine.settings.meetings.record.permission-modal.description'
|
||||
](),
|
||||
onConfirm: async () => {
|
||||
await meetingSettingsService.showScreenRecordingPermissionSetting();
|
||||
},
|
||||
cancelText: t['com.affine.recording.dismiss'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
confirmText:
|
||||
t[
|
||||
'com.affine.settings.meetings.record.permission-modal.open-setting'
|
||||
](),
|
||||
});
|
||||
}
|
||||
},
|
||||
[confirmModal, meetingSettingsService, t]
|
||||
);
|
||||
|
||||
const handleAutoTranscriptionChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
meetingSettingsService.setAutoTranscription(checked);
|
||||
},
|
||||
[meetingSettingsService]
|
||||
);
|
||||
|
||||
const handleOpenScreenRecordingPermissionSetting =
|
||||
useAsyncCallback(async () => {
|
||||
await meetingSettingsService.showScreenRecordingPermissionSetting();
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
const handleOpenSavedRecordings = useAsyncCallback(async () => {
|
||||
await meetingSettingsService.openSavedRecordings();
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
return (
|
||||
<div className={styles.meetingWrapper}>
|
||||
<SettingHeader title={t['com.affine.settings.meetings']()} />
|
||||
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.enable.title']()}
|
||||
desc={t['com.affine.settings.meetings.enable.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={handleEnabledChange}
|
||||
data-testid="meetings-enable-switch"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{recordingFeatureAvailable && (
|
||||
<>
|
||||
<SettingWrapper
|
||||
disabled={!settings.enabled}
|
||||
title={t['com.affine.settings.meetings.record.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.record.recording-mode']()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.record.recording-mode.description'
|
||||
]()}
|
||||
>
|
||||
<RecordingModeMenu />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.record.open-saved-file']()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.record.open-saved-file.description'
|
||||
]()}
|
||||
>
|
||||
<IconButton
|
||||
icon={<ArrowRightSmallIcon />}
|
||||
onClick={handleOpenSavedRecordings}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper
|
||||
disabled={!settings.enabled}
|
||||
title={t['com.affine.settings.meetings.transcription.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.meetings.transcription.auto-transcription'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.transcription.auto-transcription.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.autoTranscription}
|
||||
onChange={handleAutoTranscriptionChange}
|
||||
data-testid="meetings-auto-transcription-switch"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.settings.meetings.privacy.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording'
|
||||
]()}
|
||||
desc={
|
||||
<>
|
||||
{t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording.description'
|
||||
]()}
|
||||
{!screenRecordingPermission && (
|
||||
<span
|
||||
onClick={handleOpenScreenRecordingPermissionSetting}
|
||||
className={styles.permissionSetting}
|
||||
>
|
||||
{t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting'
|
||||
]()}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
screenRecordingPermission ? (
|
||||
<DoneIcon />
|
||||
) : (
|
||||
<InformationFillDuotoneIcon
|
||||
className={styles.noPermissionIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={handleOpenScreenRecordingPermissionSetting}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const meetingWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
});
|
||||
|
||||
export const permissionSetting = style({
|
||||
color: cssVarV2('text/link'),
|
||||
cursor: 'pointer',
|
||||
marginLeft: 4,
|
||||
});
|
||||
|
||||
export const noPermissionIcon = style({
|
||||
color: cssVarV2('button/error'),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export type SettingTab =
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| 'meetings'
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
|
||||
@@ -225,15 +225,6 @@ 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:
|
||||
@@ -274,6 +265,24 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
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_meetings: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.description',
|
||||
configurable: !isMobile && environment.isMacOs,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { encodeAudioBlobToOpus } from '@affine/core/utils/webm-encoding';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { AiJobStatus } from '@affine/graphql';
|
||||
import {
|
||||
@@ -118,7 +119,8 @@ export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
|
||||
if (!buffer) {
|
||||
throw new Error('No audio buffer available');
|
||||
}
|
||||
const blob = new Blob([buffer], { type: this.props.props.type });
|
||||
const encodedBuffer = await encodeAudioBlobToOpus(buffer, 64000);
|
||||
const blob = new Blob([encodedBuffer], { type: this.props.props.type });
|
||||
const file = new File([blob], this.props.props.name, {
|
||||
type: this.props.props.type,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { DefaultServerService, WorkspaceServerService } from '../cloud';
|
||||
import { DesktopApiService } from '../desktop-api';
|
||||
import { GlobalState } from '../storage';
|
||||
import { GlobalState, GlobalStateService } from '../storage';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { AudioAttachmentBlock } from './entities/audio-attachment-block';
|
||||
@@ -16,9 +15,11 @@ import {
|
||||
} from './providers/global-audio-state';
|
||||
import { AudioAttachmentService } from './services/audio-attachment';
|
||||
import { AudioMediaManagerService } from './services/audio-media-manager';
|
||||
import { MeetingSettingsService } from './services/meeting-settings';
|
||||
|
||||
export function configureMediaModule(framework: Framework) {
|
||||
framework
|
||||
.service(MeetingSettingsService, [GlobalStateService])
|
||||
.scope(WorkspaceScope)
|
||||
.entity(AudioMedia, [WorkspaceService])
|
||||
.entity(AudioAttachmentBlock, [AudioMediaManagerService, WorkspaceService])
|
||||
@@ -31,27 +32,18 @@ export function configureMediaModule(framework: Framework) {
|
||||
WorkspaceServerService,
|
||||
DefaultServerService,
|
||||
])
|
||||
.service(AudioAttachmentService);
|
||||
.service(AudioAttachmentService)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
]);
|
||||
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
|
||||
GlobalState,
|
||||
])
|
||||
.scope(WorkspaceScope)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
DesktopApiService,
|
||||
]);
|
||||
framework.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
|
||||
GlobalState,
|
||||
]);
|
||||
} else {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider)
|
||||
.scope(WorkspaceScope)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
]);
|
||||
framework.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { clamp } from 'lodash-es';
|
||||
import { distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import type { DesktopApiService } from '../../desktop-api';
|
||||
import { DesktopApiService } from '../../desktop-api';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
import { AudioMedia } from '../entities/audio-media';
|
||||
import type { BaseGlobalMediaStateProvider } from '../providers/global-audio-state';
|
||||
@@ -36,18 +36,13 @@ export class AudioMediaManagerService extends Service {
|
||||
});
|
||||
|
||||
private readonly mediaDisposables = new WeakMap<AudioMedia, (() => void)[]>();
|
||||
private readonly desktopApi = this.framework.getOptional(DesktopApiService);
|
||||
|
||||
constructor(
|
||||
private readonly globalMediaState: BaseGlobalMediaStateProvider,
|
||||
private readonly workbench: WorkbenchService,
|
||||
private readonly desktopApi?: DesktopApiService
|
||||
private readonly workbench: WorkbenchService
|
||||
) {
|
||||
super();
|
||||
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
this.desktopApi = undefined;
|
||||
}
|
||||
|
||||
this.disposables.push(() => {
|
||||
this.mediaPool.clear();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
MeetingSettingsKey,
|
||||
MeetingSettingsSchema,
|
||||
} from '@affine/electron/main/shared-state-schema';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { defaults } from 'lodash-es';
|
||||
|
||||
import { DesktopApiService } from '../../desktop-api';
|
||||
import type { GlobalStateService } from '../../storage';
|
||||
|
||||
const MEETING_SETTINGS_KEY: typeof MeetingSettingsKey = 'meetingSettings';
|
||||
|
||||
const defaultMeetingSettings: MeetingSettingsSchema = {
|
||||
enabled: false,
|
||||
recordingSavingMode: 'new-doc',
|
||||
autoTranscription: true,
|
||||
recordingMode: 'prompt',
|
||||
};
|
||||
|
||||
export class MeetingSettingsService extends Service {
|
||||
constructor(private readonly globalStateService: GlobalStateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly desktopApiService =
|
||||
this.framework.getOptional(DesktopApiService);
|
||||
|
||||
readonly settings$ = LiveData.computed(get => {
|
||||
const value = get(
|
||||
LiveData.from(
|
||||
this.globalStateService.globalState.watch<MeetingSettingsSchema>(
|
||||
MEETING_SETTINGS_KEY
|
||||
),
|
||||
undefined
|
||||
)
|
||||
);
|
||||
return defaults(value, defaultMeetingSettings);
|
||||
});
|
||||
|
||||
get settings() {
|
||||
return this.settings$.value;
|
||||
}
|
||||
|
||||
// we do not want the caller to directly set the settings,
|
||||
// there could be some side effects when the settings are changed.
|
||||
async setEnabled(enabled: boolean) {
|
||||
const currentEnabled = this.settings.enabled;
|
||||
if (currentEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.isRecordingFeatureAvailable())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the user enable the recording feature the first time,
|
||||
// the app may prompt the user to allow the recording feature by MacOS.
|
||||
// when the user allows the recording feature, the app may be required to restart.
|
||||
if (enabled) {
|
||||
// if the user already enabled the recording feature, we need to disable it
|
||||
const successful =
|
||||
await this.desktopApiService?.handler.recording.setupRecordingFeature();
|
||||
if (!successful) {
|
||||
throw new Error('Failed to setup recording feature');
|
||||
}
|
||||
} else {
|
||||
// check if there is any ongoing recording
|
||||
const ongoingRecording =
|
||||
await this.desktopApiService?.handler.recording.getCurrentRecording();
|
||||
if (
|
||||
ongoingRecording &&
|
||||
ongoingRecording.status !== 'new' &&
|
||||
ongoingRecording.status !== 'ready'
|
||||
) {
|
||||
throw new Error('There is an ongoing recording, please stop it first');
|
||||
}
|
||||
// if the user disabled the recording feature, we need to setup the recording feature
|
||||
await this.desktopApiService?.handler.recording.disableRecordingFeature();
|
||||
}
|
||||
|
||||
// Only update the state after successful feature setup/disable
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
setRecordingSavingMode(mode: MeetingSettingsSchema['recordingSavingMode']) {
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
recordingSavingMode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
setAutoTranscription(autoTranscription: boolean) {
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
autoTranscription,
|
||||
});
|
||||
}
|
||||
|
||||
// this is a desktop-only feature for MacOS version 14.2 and above
|
||||
async isRecordingFeatureAvailable() {
|
||||
return this.desktopApiService?.handler.recording.checkRecordingAvailable();
|
||||
}
|
||||
|
||||
async checkScreenRecordingPermission() {
|
||||
return this.desktopApiService?.handler.recording.checkScreenRecordingPermission();
|
||||
}
|
||||
|
||||
// the following methods are only available on MacOS right?
|
||||
async showScreenRecordingPermissionSetting() {
|
||||
return this.desktopApiService?.handler.recording.showScreenRecordingPermissionSetting();
|
||||
}
|
||||
|
||||
setRecordingMode = (mode: MeetingSettingsSchema['recordingMode']) => {
|
||||
const currentMode = this.settings.recordingMode;
|
||||
|
||||
if (currentMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings,
|
||||
recordingMode: mode,
|
||||
});
|
||||
};
|
||||
|
||||
async openSavedRecordings() {
|
||||
// todo: open the saved recordings folder
|
||||
await this.desktopApiService?.handler.recording.showSavedRecordings();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
};
|
||||
222
packages/frontend/core/src/utils/webm-encoding.ts
Normal file
222
packages/frontend/core/src/utils/webm-encoding.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
|
||||
|
||||
interface AudioEncodingConfig {
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
const logger = new DebugLogger('webm-encoding');
|
||||
|
||||
/**
|
||||
* Creates and configures an Opus encoder with the given settings
|
||||
*/
|
||||
async function createOpusEncoder(config: AudioEncodingConfig): Promise<{
|
||||
encoder: AudioEncoder;
|
||||
encodedChunks: EncodedAudioChunk[];
|
||||
}> {
|
||||
const encodedChunks: EncodedAudioChunk[] = [];
|
||||
const encoder = new AudioEncoder({
|
||||
output: chunk => {
|
||||
encodedChunks.push(chunk);
|
||||
},
|
||||
error: err => {
|
||||
throw new Error(`Encoding error: ${err}`);
|
||||
},
|
||||
});
|
||||
|
||||
encoder.configure({
|
||||
codec: 'opus',
|
||||
sampleRate: config.sampleRate,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
bitrate: config.bitrate ?? 64000,
|
||||
});
|
||||
|
||||
return { encoder, encodedChunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes audio frames using the provided encoder
|
||||
*/
|
||||
async function encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
encoder,
|
||||
}: {
|
||||
audioData: Float32Array;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
encoder: AudioEncoder;
|
||||
}): Promise<void> {
|
||||
const CHUNK_SIZE = numberOfChannels * 1024;
|
||||
let offset = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < audioData.length; i += CHUNK_SIZE) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, audioData.length - i);
|
||||
const chunk = audioData.subarray(i, i + chunkSize);
|
||||
|
||||
const frame = new AudioData({
|
||||
format: 'f32',
|
||||
sampleRate,
|
||||
numberOfFrames: chunk.length / numberOfChannels,
|
||||
numberOfChannels,
|
||||
timestamp: (offset * 1000000) / sampleRate,
|
||||
data: chunk,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
offset += chunk.length / numberOfChannels;
|
||||
}
|
||||
} finally {
|
||||
await encoder.flush();
|
||||
encoder.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebM container with the encoded audio chunks
|
||||
*/
|
||||
function muxToWebM(
|
||||
encodedChunks: EncodedAudioChunk[],
|
||||
config: AudioEncodingConfig
|
||||
): Uint8Array {
|
||||
const target = new ArrayBufferTarget();
|
||||
const muxer = new Muxer({
|
||||
target,
|
||||
audio: {
|
||||
codec: 'A_OPUS',
|
||||
sampleRate: config.sampleRate,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
},
|
||||
});
|
||||
|
||||
for (const chunk of encodedChunks) {
|
||||
muxer.addAudioChunk(chunk, {});
|
||||
}
|
||||
|
||||
muxer.finalize();
|
||||
return new Uint8Array(target.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes raw audio data to Opus in WebM container.
|
||||
*/
|
||||
export async function encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}: {
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
}): Promise<Uint8Array> {
|
||||
logger.debug('Encoding raw buffer to Opus');
|
||||
const response = await fetch(new URL(filepath, location.origin));
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const { encoder, encodedChunks } = await createOpusEncoder({
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
});
|
||||
|
||||
// Process the stream
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Float32Array[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new Float32Array(value.buffer));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Combine all chunks into a single Float32Array
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const audioData = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
audioData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
await encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
encoder,
|
||||
});
|
||||
|
||||
const webm = muxToWebM(encodedChunks, { sampleRate, numberOfChannels });
|
||||
logger.debug('Encoded raw buffer to Opus');
|
||||
return webm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an audio file Blob to Opus in WebM container with specified bitrate.
|
||||
* @param blob Input audio file blob (supports any browser-decodable format)
|
||||
* @param targetBitrate Target bitrate in bits per second (bps)
|
||||
* @returns Promise resolving to encoded WebM data as Uint8Array
|
||||
*/
|
||||
export async function encodeAudioBlobToOpus(
|
||||
blob: Blob | ArrayBuffer | Uint8Array,
|
||||
targetBitrate: number = 64000
|
||||
): Promise<Uint8Array> {
|
||||
const audioContext = new AudioContext();
|
||||
logger.debug('Encoding audio blob to Opus');
|
||||
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
if (blob instanceof Blob) {
|
||||
buffer = await blob.arrayBuffer();
|
||||
} else if (blob instanceof Uint8Array) {
|
||||
buffer =
|
||||
blob.buffer instanceof ArrayBuffer ? blob.buffer : blob.slice().buffer;
|
||||
} else {
|
||||
buffer = blob;
|
||||
}
|
||||
|
||||
const audioBuffer = await audioContext.decodeAudioData(buffer);
|
||||
|
||||
const config: AudioEncodingConfig = {
|
||||
sampleRate: audioBuffer.sampleRate,
|
||||
numberOfChannels: audioBuffer.numberOfChannels,
|
||||
bitrate: targetBitrate,
|
||||
};
|
||||
|
||||
const { encoder, encodedChunks } = await createOpusEncoder(config);
|
||||
|
||||
// Combine all channels into a single Float32Array
|
||||
const audioData = new Float32Array(
|
||||
audioBuffer.length * config.numberOfChannels
|
||||
);
|
||||
for (let channel = 0; channel < config.numberOfChannels; channel++) {
|
||||
const channelData = audioBuffer.getChannelData(channel);
|
||||
for (let i = 0; i < channelData.length; i++) {
|
||||
audioData[i * config.numberOfChannels + channel] = channelData[i];
|
||||
}
|
||||
}
|
||||
|
||||
await encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
sampleRate: config.sampleRate,
|
||||
encoder,
|
||||
});
|
||||
|
||||
const webm = muxToWebM(encodedChunks, config);
|
||||
logger.debug('Encoded audio blob to Opus');
|
||||
return webm;
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"ar": 93,
|
||||
"ar": 92,
|
||||
"ca": 4,
|
||||
"da": 4,
|
||||
"de": 93,
|
||||
"el-GR": 93,
|
||||
"de": 92,
|
||||
"el-GR": 92,
|
||||
"en": 100,
|
||||
"es-AR": 93,
|
||||
"es-CL": 95,
|
||||
"es": 93,
|
||||
"fa": 93,
|
||||
"fr": 93,
|
||||
"es-AR": 92,
|
||||
"es-CL": 93,
|
||||
"es": 92,
|
||||
"fa": 92,
|
||||
"fr": 92,
|
||||
"hi": 2,
|
||||
"it-IT": 93,
|
||||
"it-IT": 92,
|
||||
"it": 1,
|
||||
"ja": 93,
|
||||
"ko": 59,
|
||||
"pl": 93,
|
||||
"pt-BR": 93,
|
||||
"ru": 93,
|
||||
"sv-SE": 93,
|
||||
"uk": 93,
|
||||
"ja": 92,
|
||||
"ko": 58,
|
||||
"pl": 92,
|
||||
"pt-BR": 92,
|
||||
"ru": 92,
|
||||
"sv-SE": 92,
|
||||
"uk": 92,
|
||||
"ur": 2,
|
||||
"zh-Hans": 93,
|
||||
"zh-Hant": 93
|
||||
"zh-Hans": 92,
|
||||
"zh-Hant": 92
|
||||
}
|
||||
|
||||
@@ -5327,6 +5327,94 @@ export function useAFFiNEI18N(): {
|
||||
* `Use transparency effect on the sidebar.`
|
||||
*/
|
||||
["com.affine.settings.translucent-style-description"](): string;
|
||||
/**
|
||||
* `Meetings`
|
||||
*/
|
||||
["com.affine.settings.meetings"](): string;
|
||||
/**
|
||||
* `Enable meeting notes`
|
||||
*/
|
||||
["com.affine.settings.meetings.enable.title"](): string;
|
||||
/**
|
||||
* `Utilize the meeting notes and AI summarization features provided by AFFiNE.`
|
||||
*/
|
||||
["com.affine.settings.meetings.enable.description"](): string;
|
||||
/**
|
||||
* `Meeting recording`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.header"](): string;
|
||||
/**
|
||||
* `When meeting starts`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.recording-mode"](): string;
|
||||
/**
|
||||
* `Choose the behavior when the meeting starts.`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.recording-mode.description"](): string;
|
||||
/**
|
||||
* `Open saved recordings`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.open-saved-file"](): string;
|
||||
/**
|
||||
* `Open the locally stored recording files.`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.open-saved-file.description"](): string;
|
||||
/**
|
||||
* `Transcription with AI`
|
||||
*/
|
||||
["com.affine.settings.meetings.transcription.header"](): string;
|
||||
/**
|
||||
* `Auto transcription`
|
||||
*/
|
||||
["com.affine.settings.meetings.transcription.auto-transcription"](): string;
|
||||
/**
|
||||
* `Automatically transcribe the meeting notes.`
|
||||
*/
|
||||
["com.affine.settings.meetings.transcription.auto-transcription.description"](): string;
|
||||
/**
|
||||
* `Privacy & Security`
|
||||
*/
|
||||
["com.affine.settings.meetings.privacy.header"](): string;
|
||||
/**
|
||||
* `Screen & System audio recording`
|
||||
*/
|
||||
["com.affine.settings.meetings.privacy.screen-system-audio-recording"](): string;
|
||||
/**
|
||||
* `The Meeting feature requires permission to be used.`
|
||||
*/
|
||||
["com.affine.settings.meetings.privacy.screen-system-audio-recording.description"](): string;
|
||||
/**
|
||||
* `Click to allow`
|
||||
*/
|
||||
["com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting"](): string;
|
||||
/**
|
||||
* `Do nothing`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.recording-mode.none"](): string;
|
||||
/**
|
||||
* `Auto start recording`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.recording-mode.auto-start"](): string;
|
||||
/**
|
||||
* `Show a recording prompt`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.recording-mode.prompt"](): string;
|
||||
/**
|
||||
* `Screen & System Audio Recording`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.permission-modal.title"](): string;
|
||||
/**
|
||||
* `AFFiNE will generate meeting notes by recording your meetings. Authorization to "Screen & System Audio Recording" is necessary.`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.permission-modal.description"](): string;
|
||||
/**
|
||||
* `Save meeting's recording block to`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.save-mode"](): string;
|
||||
/**
|
||||
* `Open System Settings`
|
||||
*/
|
||||
["com.affine.settings.meetings.record.permission-modal.open-setting"](): string;
|
||||
/**
|
||||
* `Workspace`
|
||||
*/
|
||||
@@ -5583,6 +5671,14 @@ export function useAFFiNEI18N(): {
|
||||
* `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;
|
||||
/**
|
||||
* `Meetings`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-meetings.name"](): string;
|
||||
/**
|
||||
* `Meetings allows you to record and transcribe meetings. Don't forget to enable it in AFFiNE settings.`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-meetings.description"](): string;
|
||||
/**
|
||||
* `Editor RTL`
|
||||
*/
|
||||
@@ -7449,10 +7545,22 @@ export function useAFFiNEI18N(): {
|
||||
* `Audio activity`
|
||||
*/
|
||||
["com.affine.recording.new"](): string;
|
||||
/**
|
||||
* `Finished`
|
||||
*/
|
||||
["com.affine.recording.success.prompt"](): string;
|
||||
/**
|
||||
* `Open app`
|
||||
*/
|
||||
["com.affine.recording.ready"](): string;
|
||||
["com.affine.recording.success.button"](): string;
|
||||
/**
|
||||
* `Failed to save`
|
||||
*/
|
||||
["com.affine.recording.failed.prompt"](): string;
|
||||
/**
|
||||
* `Open file`
|
||||
*/
|
||||
["com.affine.recording.failed.button"](): string;
|
||||
/**
|
||||
* `{{appName}}'s audio`
|
||||
*/
|
||||
|
||||
@@ -1328,6 +1328,28 @@
|
||||
"com.affine.settings.suggestion-2": "Love our app? <1>Star us on GitHub</1> and <2>create issues</2> for your valuable feedback!",
|
||||
"com.affine.settings.translucent-style": "Translucent UI on the sidebar",
|
||||
"com.affine.settings.translucent-style-description": "Use transparency effect on the sidebar.",
|
||||
"com.affine.settings.meetings": "Meetings",
|
||||
"com.affine.settings.meetings.enable.title": "Enable meeting notes",
|
||||
"com.affine.settings.meetings.enable.description": "Utilize the meeting notes and AI summarization features provided by AFFiNE.",
|
||||
"com.affine.settings.meetings.record.header": "Meeting recording",
|
||||
"com.affine.settings.meetings.record.recording-mode": "When meeting starts",
|
||||
"com.affine.settings.meetings.record.recording-mode.description": "Choose the behavior when the meeting starts.",
|
||||
"com.affine.settings.meetings.record.open-saved-file": "Open saved recordings",
|
||||
"com.affine.settings.meetings.record.open-saved-file.description": "Open the locally stored recording files.",
|
||||
"com.affine.settings.meetings.transcription.header": "Transcription with AI",
|
||||
"com.affine.settings.meetings.transcription.auto-transcription": "Auto transcription",
|
||||
"com.affine.settings.meetings.transcription.auto-transcription.description": "Automatically transcribe the meeting notes.",
|
||||
"com.affine.settings.meetings.privacy.header": "Privacy & Security",
|
||||
"com.affine.settings.meetings.privacy.screen-system-audio-recording": "Screen & System audio recording",
|
||||
"com.affine.settings.meetings.privacy.screen-system-audio-recording.description": "The Meeting feature requires permission to be used.",
|
||||
"com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting": "Click to allow",
|
||||
"com.affine.settings.meetings.record.recording-mode.none": "Do nothing",
|
||||
"com.affine.settings.meetings.record.recording-mode.auto-start": "Auto start recording",
|
||||
"com.affine.settings.meetings.record.recording-mode.prompt": "Show a recording prompt",
|
||||
"com.affine.settings.meetings.record.permission-modal.title": "Screen & System Audio Recording",
|
||||
"com.affine.settings.meetings.record.permission-modal.description": "AFFiNE will generate meeting notes by recording your meetings. Authorization to \"Screen & System Audio Recording\" is necessary.",
|
||||
"com.affine.settings.meetings.record.save-mode": "Save meeting's recording block to",
|
||||
"com.affine.settings.meetings.record.permission-modal.open-setting": "Open System Settings",
|
||||
"com.affine.settings.workspace": "Workspace",
|
||||
"com.affine.settings.workspace.description": "You can view current workspace's information here.",
|
||||
"com.affine.settings.workspace.experimental-features": "Experimental features",
|
||||
@@ -1392,6 +1414,8 @@
|
||||
"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-meetings.name": "Meetings",
|
||||
"com.affine.settings.workspace.experimental-features.enable-meetings.description": "Meetings allows you to record and transcribe meetings. Don't forget to enable it in AFFiNE settings.",
|
||||
"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.experimental-features.enable-edgeless-scribbled-style.name": "Edgeless scribbled style",
|
||||
@@ -1864,7 +1888,10 @@
|
||||
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",
|
||||
"com.affine.audio.transcribe.non-owner.confirm.message": "Please contact <1>{{user}}</1> to upgrade AI rights or resend the attachment.",
|
||||
"com.affine.recording.new": "Audio activity",
|
||||
"com.affine.recording.ready": "Open app",
|
||||
"com.affine.recording.success.prompt": "Finished",
|
||||
"com.affine.recording.success.button": "Open app",
|
||||
"com.affine.recording.failed.prompt": "Failed to save",
|
||||
"com.affine.recording.failed.button": "Open file",
|
||||
"com.affine.recording.recording": "{{appName}}'s audio",
|
||||
"com.affine.recording.recording.unnamed": "Audio recording",
|
||||
"com.affine.recording.start": "Start",
|
||||
|
||||
@@ -448,7 +448,7 @@ impl TappableApplication {
|
||||
|
||||
#[napi]
|
||||
pub struct ApplicationListChangedSubscriber {
|
||||
listener_block: *const Block<dyn Fn(u32, *mut c_void)>,
|
||||
listener_block: RcBlock<dyn Fn(u32, *mut c_void)>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -464,7 +464,9 @@ impl ApplicationListChangedSubscriber {
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
},
|
||||
ptr::null_mut(),
|
||||
self.listener_block.cast_mut().cast(),
|
||||
(&*self.listener_block as *const Block<dyn Fn(u32, *mut c_void)>)
|
||||
.cast_mut()
|
||||
.cast(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
@@ -554,7 +556,7 @@ impl ShareableContent {
|
||||
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
});
|
||||
let listener_block = &*callback_block as *const Block<dyn Fn(u32, *mut c_void)>;
|
||||
|
||||
let status = unsafe {
|
||||
AudioObjectAddPropertyListenerBlock(
|
||||
kAudioObjectSystemObject,
|
||||
@@ -564,7 +566,9 @@ impl ShareableContent {
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
},
|
||||
ptr::null_mut(),
|
||||
listener_block.cast_mut().cast(),
|
||||
(&*callback_block as *const Block<dyn Fn(u32, *mut c_void)>)
|
||||
.cast_mut()
|
||||
.cast(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
@@ -573,7 +577,9 @@ impl ShareableContent {
|
||||
"Failed to add property listener",
|
||||
));
|
||||
}
|
||||
Ok(ApplicationListChangedSubscriber { listener_block })
|
||||
Ok(ApplicationListChangedSubscriber {
|
||||
listener_block: callback_block,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
Reference in New Issue
Block a user