diff --git a/docs/calls-protocol.md b/docs/calls-protocol.md index 13ba6ba8eb4..28865821b23 100644 --- a/docs/calls-protocol.md +++ b/docs/calls-protocol.md @@ -72,3 +72,5 @@ Room owners can appoint or dismiss an active non-host participant through `calls Only the current host can transfer an open room to an active non-host participant through `calls/rooms/transfer-host` (`roomId`, `participantId`, `expectedRevision`). Ownership and both participant roles change in one transaction: the target becomes host and the previous host remains a speaker, so their later exit does not end the room. The target's moderator assignment and speaker request are cleared. Existing participants retain access to non-public rooms; follower visibility subsequently follows the new owner. A target who already owns an active personal room is rejected with `CALLS_ACTIVE_ATTACHMENT`. A `participant` event refreshes the room and participant snapshot, and the host timeout is renewed. Host transfer checks the target’s current Calls participation permission. Its moderation log records the original actor, target and committed revision. Repeating the same request with the original `expectedRevision` can resend notifications after a committed transfer without changing ownership again, provided the recorded target is still host. Clients refresh the committed state on an ambiguous failure and retry that notification once. The previous host receives a role update, and the new host’s participant update refreshes the room snapshot for viewers. + +A participant may publish multiple screen videos. To attach screen audio, publish `mediaSource: screen` with `screenPublicationId` referencing their current screen video publication. This publishes an audio track, and reconcile includes its `screenPublicationId` so receivers can control each share’s volume separately from microphone volume. Screen capture requests audio through the browser picker; only audio explicitly supplied by the browser is published. Stopping a share closes both of its tracks. diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 36c24544d68..a25c05ec48c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2747,6 +2747,8 @@ _calls: stopCamera: "カメラをオフ" screenSharing: "画面共有" startScreenSharing: "画面を共有" + addScreenSharing: "画面共有を追加" + screenAudioVolume: "画面共有の音量" stopScreenSharing: "共有を停止" videoPermissionDenied: "カメラへのアクセスを許可してください。" cameraNotFound: "カメラが見つかりません。" diff --git a/packages/backend/src/core/calls/CallsMediaBindingService.ts b/packages/backend/src/core/calls/CallsMediaBindingService.ts index 2242d34aec9..bb9fd0931b6 100644 --- a/packages/backend/src/core/calls/CallsMediaBindingService.ts +++ b/packages/backend/src/core/calls/CallsMediaBindingService.ts @@ -20,6 +20,7 @@ export type CallsPublicationBinding = { providerMid: string | null; mediaKind: 'audio' | 'video'; mediaSource?: 'microphone' | 'camera' | 'screen'; + screenPublicationId?: string; createdAt: string; }; diff --git a/packages/backend/src/core/calls/CallsMediaService.ts b/packages/backend/src/core/calls/CallsMediaService.ts index cdc8357af2f..f634b797974 100644 --- a/packages/backend/src/core/calls/CallsMediaService.ts +++ b/packages/backend/src/core/calls/CallsMediaService.ts @@ -97,6 +97,7 @@ export class CallsMediaService { generation: number; mid: string; mediaSource?: 'microphone' | 'camera' | 'screen'; + screenPublicationId?: string; sessionDescription: CloudflareRealtimeSessionDescription; }): Promise<{ publicationId: string; negotiation: CloudflareRealtimeTracksResponse }> { const participant = await this.authorizeParticipant(user, input.roomId); @@ -105,7 +106,11 @@ export class CallsMediaService { if (connection.sessionId == null) throw new CallsMediaAccessError(); const mediaSource = input.mediaSource ?? 'microphone'; await this.roomService.assertCanPublish(user, mediaSource); - const mediaKind = mediaSource === 'microphone' ? 'audio' : 'video'; + if (input.screenPublicationId != null) { + const screen = await this.bindingService.getPublication(input.screenPublicationId); + if (mediaSource !== 'screen' || screen.roomId !== input.roomId || screen.participantId !== participant.id || screen.connectionId !== input.connectionId || screen.generation !== input.generation || screen.mediaKind !== 'video' || screen.mediaSource !== 'screen') throw new CallsMediaAccessError(); + } + const mediaKind = mediaSource === 'microphone' || input.screenPublicationId != null ? 'audio' : 'video'; const trackName = `${mediaSource}-${participant.id}-${input.generation}-${input.mid}`; await this.quotaService.reserveTrack(connection.applicationId, trackName); let providerMid: string | null = null; @@ -128,7 +133,7 @@ export class CallsMediaService { providerSessionId: connection.sessionId, providerTrackName: track?.trackName ?? trackName, providerMid: track?.mid ?? input.mid, - mediaKind, mediaSource, + mediaKind, mediaSource, screenPublicationId: input.screenPublicationId, }); await this.liveConnectionService.assertCurrent(participant.id, input.connectionId, input.generation); const room = await this.roomService.getRoom(input.roomId); @@ -206,11 +211,11 @@ export class CallsMediaService { return response; } - public async reconcile(user: MiUser, roomId: string): Promise<{ roomRevision: number; publications: Array<{ id: string; participantId: string; mediaKind: 'audio' | 'video'; mediaSource: 'microphone' | 'camera' | 'screen' }> }> { + public async reconcile(user: MiUser, roomId: string): Promise<{ roomRevision: number; publications: Array<{ id: string; participantId: string; mediaKind: 'audio' | 'video'; mediaSource: 'microphone' | 'camera' | 'screen'; screenPublicationId?: string }> }> { const snapshot = await this.roomService.snapshot(user, roomId); const activeSpeakers = new Set(snapshot.participants.filter(p => p.role !== 'listener').map(p => p.id)); const publications = (await this.bindingService.listRoomPublications(roomId)).filter(binding => activeSpeakers.has(binding.participantId)); - return { roomRevision: snapshot.room.revision, publications: publications.map(binding => ({ id: binding.id, participantId: binding.participantId, mediaKind: binding.mediaKind, mediaSource: binding.mediaSource ?? 'microphone' })) }; + return { roomRevision: snapshot.room.revision, publications: publications.map(binding => ({ id: binding.id, participantId: binding.participantId, mediaKind: binding.mediaKind, mediaSource: binding.mediaSource ?? 'microphone', ...(binding.screenPublicationId == null ? {} : { screenPublicationId: binding.screenPublicationId }) })) }; } public async heartbeat(user: MiUser, roomId: string, connectionId: string, generation: number): Promise { diff --git a/packages/backend/src/server/api/endpoints/calls/media/publish.ts b/packages/backend/src/server/api/endpoints/calls/media/publish.ts index df70c1cb2bf..4d71d4b57be 100644 --- a/packages/backend/src/server/api/endpoints/calls/media/publish.ts +++ b/packages/backend/src/server/api/endpoints/calls/media/publish.ts @@ -10,7 +10,7 @@ import { CallsOperationGuardService } from '@/core/calls/CallsOperationGuardServ import { Endpoint } from '@/server/api/endpoint-base.js'; import { callsMediaApiError, callsMediaErrors, executeCallsMediaOperation, negotiationResponseSchema, operationIdParam, sanitizeNegotiation, sessionDescriptionParam, verifyCallsMediaCredential } from '../_media-shared.js'; export const meta = { tags: ['calls'], stability: 'experimental', requireCredential: true, prohibitMoved: true, kind: 'write:calls', limit: { key: 'calls-media', duration: 60 * 1000, max: 120 }, errors: callsMediaErrors, res: { type: 'object', optional: false, nullable: false, properties: { publicationId: { type: 'string', format: 'id', optional: false, nullable: false }, negotiation: negotiationResponseSchema } } } as const; -export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, participantId: { type: 'string', format: 'misskey:id' }, connectionId: { type: 'string', minLength: 8, maxLength: 128 }, generation: { type: 'integer', minimum: 1 }, operationId: operationIdParam, mediaCredential: { type: 'string', minLength: 32, maxLength: 4096 }, mediaSource: { type: 'string', enum: ['microphone', 'camera', 'screen'], default: 'microphone' }, mid: { type: 'string', minLength: 1, maxLength: 64 }, sessionDescription: sessionDescriptionParam }, required: ['roomId', 'participantId', 'connectionId', 'generation', 'operationId', 'mediaCredential', 'mid', 'sessionDescription'] } as const; +export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' }, participantId: { type: 'string', format: 'misskey:id' }, connectionId: { type: 'string', minLength: 8, maxLength: 128 }, generation: { type: 'integer', minimum: 1 }, operationId: operationIdParam, mediaCredential: { type: 'string', minLength: 32, maxLength: 4096 }, mediaSource: { type: 'string', enum: ['microphone', 'camera', 'screen'], default: 'microphone' }, screenPublicationId: { type: 'string', format: 'misskey:id' }, mid: { type: 'string', minLength: 1, maxLength: 64 }, sessionDescription: sessionDescriptionParam }, required: ['roomId', 'participantId', 'connectionId', 'generation', 'operationId', 'mediaCredential', 'mid', 'sessionDescription'] } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor(service: CallsMediaService, credentials: CallsMediaCredentialService, guard: CallsOperationGuardService) { super(meta, paramDef, async (ps, me, token) => { try { verifyCallsMediaCredential(credentials, ps.mediaCredential, me, token, ps, true); return await executeCallsMediaOperation(guard, me, token, ps, 'publish', async () => { const result = await service.publish(me, ps); return { publicationId: result.publicationId, negotiation: sanitizeNegotiation(result.negotiation) }; }); } catch (error) { callsMediaApiError(error); } }); } diff --git a/packages/backend/src/server/api/endpoints/calls/media/reconcile.ts b/packages/backend/src/server/api/endpoints/calls/media/reconcile.ts index 95100e21830..e728a666f02 100644 --- a/packages/backend/src/server/api/endpoints/calls/media/reconcile.ts +++ b/packages/backend/src/server/api/endpoints/calls/media/reconcile.ts @@ -9,7 +9,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { callsMediaApiError, callsMediaErrors } from '../_media-shared.js'; export const meta = { tags: ['calls'], stability: 'experimental', requireCredential: true, kind: 'read:calls', errors: callsMediaErrors, res: { type: 'object', optional: false, nullable: false, properties: { roomRevision: { type: 'integer', optional: false, nullable: false }, - publications: { type: 'array', optional: false, nullable: false, items: { type: 'object', properties: { id: { type: 'string', format: 'id', optional: false, nullable: false }, participantId: { type: 'string', format: 'id', optional: false, nullable: false }, mediaSource: { type: 'string', enum: ['microphone', 'camera', 'screen'], optional: false, nullable: false }, mediaKind: { type: 'string', enum: ['audio', 'video'], optional: false, nullable: false } } } }, + publications: { type: 'array', optional: false, nullable: false, items: { type: 'object', properties: { id: { type: 'string', format: 'id', optional: false, nullable: false }, participantId: { type: 'string', format: 'id', optional: false, nullable: false }, mediaSource: { type: 'string', enum: ['microphone', 'camera', 'screen'], optional: false, nullable: false }, screenPublicationId: { type: 'string', format: 'id', optional: true, nullable: false }, mediaKind: { type: 'string', enum: ['audio', 'video'], optional: false, nullable: false } } } }, } } } as const; export const paramDef = { type: 'object', properties: { roomId: { type: 'string', format: 'misskey:id' } }, required: ['roomId'] } as const; @Injectable() diff --git a/packages/backend/test/unit/core/calls/CallsMediaService.ts b/packages/backend/test/unit/core/calls/CallsMediaService.ts index d4907437aaf..09558aaebbc 100644 --- a/packages/backend/test/unit/core/calls/CallsMediaService.ts +++ b/packages/backend/test/unit/core/calls/CallsMediaService.ts @@ -137,6 +137,23 @@ describe('CallsMediaService authorization boundaries', () => { expect(fixture.quota.reserveTrack).toHaveBeenCalledWith('app-a', `${mediaSource}-participant-a-2-1`); }); + test('publishes screen audio linked to the current owned screen video', async () => { + const fixture = createFixture(); + fixture.bindings.getPublication.mockResolvedValue({ id: 'screen-a', roomId: room.id, participantId: 'participant-a', connectionId: 'connection-a', generation: 2, mediaKind: 'video', mediaSource: 'screen' }); + fixture.provider.addTracks.mockResolvedValue({ tracks: [{ mid: '2' }] }); + fixture.bindings.createPublication.mockResolvedValue({ id: 'screen-audio' }); + await fixture.service.publish(user, { roomId: room.id, connectionId: 'connection-a', generation: 2, mid: '2', mediaSource: 'screen', screenPublicationId: 'screen-a', sessionDescription: { type: 'offer', sdp: 'sdp' } }); + expect(fixture.provider.addTracks).toHaveBeenCalledWith('session-a', [expect.objectContaining({ kind: 'audio' })], expect.anything()); + expect(fixture.bindings.createPublication).toHaveBeenCalledWith(expect.objectContaining({ mediaKind: 'audio', mediaSource: 'screen', screenPublicationId: 'screen-a' })); + }); + + test.each([{ participantId: 'participant-b' }, { roomId: 'room-b' }, { generation: 1 }, { mediaSource: 'camera' }])('rejects attaching screen audio to a foreign or stale publication: %s', async invalid => { + const fixture = createFixture(); + fixture.bindings.getPublication.mockResolvedValue({ roomId: room.id, participantId: 'participant-a', connectionId: 'connection-a', generation: 2, mediaKind: 'video', mediaSource: 'screen', ...invalid }); + await expect(fixture.service.publish(user, { roomId: room.id, connectionId: 'connection-a', generation: 2, mid: '2', mediaSource: 'screen', screenPublicationId: 'screen-a', sessionDescription: { type: 'offer', sdp: 'sdp' } })).rejects.toBeInstanceOf(CallsMediaAccessError); + expect(fixture.provider.addTracks).not.toHaveBeenCalled(); + }); + test('maps subscription mids to public IDs even when provider results are reordered', async () => { const fixture = createFixture('listener'); const publications = [ diff --git a/packages/frontend/src/components/MkCallsRoomWindow.vue b/packages/frontend/src/components/MkCallsRoomWindow.vue index 327031c7011..66c7ba8c33e 100644 --- a/packages/frontend/src/components/MkCallsRoomWindow.vue +++ b/packages/frontend/src/components/MkCallsRoomWindow.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
diff --git a/packages/frontend/src/components/MkCallsScreenWindow.vue b/packages/frontend/src/components/MkCallsScreenWindow.vue index 74e42e101cd..51417207631 100644 --- a/packages/frontend/src/components/MkCallsScreenWindow.vue +++ b/packages/frontend/src/components/MkCallsScreenWindow.vue @@ -6,13 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkCallsVideo.stories.impl.ts b/packages/frontend/src/components/MkCallsVideo.stories.impl.ts index 9c07b956554..46a577ab61d 100644 --- a/packages/frontend/src/components/MkCallsVideo.stories.impl.ts +++ b/packages/frontend/src/components/MkCallsVideo.stories.impl.ts @@ -32,3 +32,5 @@ export const Default = { export const Speaking = { ...Default, args: { ...Default.args, speaking: true } } satisfies StoryObj; export const Screen = { ...Default, args: { ...Default.args, screenWindow: true } } satisfies StoryObj; + +export const ScreenWithAudio = { ...Screen, args: { ...Screen.args, audioVolume: 75 } } satisfies StoryObj; diff --git a/packages/frontend/src/components/MkCallsVideo.vue b/packages/frontend/src/components/MkCallsVideo.vue index c68a9f44b2b..eb1fb364ac6 100644 --- a/packages/frontend/src/components/MkCallsVideo.vue +++ b/packages/frontend/src/components/MkCallsVideo.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
{{ label }}
@@ -24,8 +25,9 @@ const props = defineProps<{ focused?: boolean; screenWindow?: boolean; screenWindowActive?: boolean; + audioVolume?: number; }>(); -const emit = defineEmits<{ (ev: 'select' | 'screenWindow'): void }>(); +const emit = defineEmits<{ (ev: 'select' | 'screenWindow'): void; (ev: 'volume', value: number): void }>(); const video = shallowRef(null); const needsPlayback = shallowRef(false); @@ -99,6 +101,9 @@ watch([video, () => props.stream], ([element, stream]) => { white-space: nowrap; } +.volume { position: absolute; top: 8px; right: 8px; display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 8px; background: var(--MI_THEME-panel); } +.volume > input { width: 100px; accent-color: var(--MI_THEME-accent); } + .windowButton { position: absolute; bottom: 8px; right: 8px; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; background: var(--MI_THEME-panel); font-size: 20px; opacity: 0; transition: opacity 0.18s ease; } .play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--MI_THEME-panel); } @media (pointer: coarse) { .caption, .windowButton { opacity: 1; } } diff --git a/packages/frontend/src/utility/calls-media.ts b/packages/frontend/src/utility/calls-media.ts index 78a9b80ea0d..2296742bfd5 100644 --- a/packages/frontend/src/utility/calls-media.ts +++ b/packages/frontend/src/utility/calls-media.ts @@ -11,7 +11,9 @@ import { misskeyApi } from '@/utility/misskey-api.js'; export type CallsVideoSource = 'camera' | 'screen'; export type CallsVideoQuality = { height: 480 | 720 | 1080 | 1440 | 2160 | 'source'; frameRate: 15 | 30 | 60 | 90 | 120 | 144 }; -export type CallsRemotePublication = { id: string; participantId: string; mediaKind: 'audio' | 'video'; mediaSource: 'microphone' | CallsVideoSource }; +export type CallsRemotePublication = { id: string; participantId: string; mediaKind: 'audio' | 'video'; mediaSource: 'microphone' | CallsVideoSource; screenPublicationId?: string }; +type CallsLocalTrack = { track: MediaStreamTrack; publicationId?: string; transceiver?: RTCRtpTransceiver }; +type CallsLocalVideo = CallsLocalTrack & { source: CallsVideoSource; audio?: CallsLocalTrack }; type CallsMicrophone = { track: MediaStreamTrack; processing: CallsNoiseSuppression | null }; function videoConstraints(quality: CallsVideoQuality): MediaTrackConstraints { @@ -35,7 +37,7 @@ export class CallsMediaController { public localTrack: MediaStreamTrack | null = null; private microphone: CallsMicrophone | null = null; private peer: RTCPeerConnection | null = null; - private localVideos = new Map(); + private localVideos = new Map(); private remotePublications = new Map(); private receivedRemoteTracks = new Map(); private connectionId: string = crypto.randomUUID(); @@ -71,7 +73,7 @@ export class CallsMediaController { previousConnection?: { connectionId: string; generation: number }, private replaceExisting = false, private videoCallbacks?: { - localTrack: (source: CallsVideoSource, track: MediaStreamTrack | null) => void; + localTrack: (source: CallsVideoSource, track: MediaStreamTrack | null, id: string) => void; microphoneTrack?: (track: MediaStreamTrack | null) => void; noiseSuppressionChanged?: (enabled: boolean) => void; remoteRemoved: (publicationId: string) => void; @@ -249,10 +251,14 @@ export class CallsMediaController { this.publications.add(result.publicationId); await this.applyNegotiation(result.negotiation); } - for (const [source, video] of this.localVideos) { + for (const video of this.localVideos.values()) { if (video.track.readyState === 'ended') continue; video.publicationId = undefined; - await this.publishVideo(source, video); + await this.publishTrack(video.source, video); + if (video.audio != null && video.audio.track.readyState !== 'ended') { + video.audio.publicationId = undefined; + await this.publishTrack('screen', video.audio, video.publicationId); + } } const subscribed = await this.reconcileNow(); // SDP exchanges stay serialized, but receiving need not wait for the sender's transport. @@ -283,13 +289,24 @@ export class CallsMediaController { await this.ensureCredential(); const authoritative = await misskeyApi('calls/media/reconcile', { roomId: this.roomId }); const authoritativeOwnIds = new Set(authoritative.publications.filter(publication => publication.participantId === this.participantId).map(publication => publication.id)); - for (const [source, video] of this.localVideos) { + for (const [id, video] of this.localVideos) { + if (video.audio?.publicationId != null && !authoritativeOwnIds.has(video.audio.publicationId)) { + video.audio.track.stop(); + await video.audio.transceiver?.sender.replaceTrack(null); + this.publications.delete(video.audio.publicationId); + video.audio = undefined; + } if (video.publicationId == null || authoritativeOwnIds.has(video.publicationId)) continue; video.track.stop(); - this.localVideos.delete(source); + video.audio?.track.stop(); + this.localVideos.delete(id); this.publications.delete(video.publicationId); - this.videoCallbacks?.localTrack(source, null); + this.videoCallbacks?.localTrack(video.source, null, id); await video.transceiver?.sender.replaceTrack(null); + if (video.audio != null) { + await video.audio.transceiver?.sender.replaceTrack(null); + if (video.audio.publicationId != null) this.publications.delete(video.audio.publicationId); + } } const authoritativeRemoteIds = new Set(authoritative.publications.filter(publication => publication.participantId !== this.participantId).map(publication => publication.id)); for (const publicationId of this.subscribedPublications) { @@ -374,34 +391,46 @@ export class CallsMediaController { } public async startVideo(source: CallsVideoSource, deviceId?: string, quality: CallsVideoQuality = { height: 720, frameRate: 30 }, previewStream?: MediaStream): Promise { - if (this.role === 'listener' || this.isClosed() || this.peer == null || this.localVideos.has(source)) { + if (this.role === 'listener' || this.isClosed() || this.peer == null || (source === 'camera' && this.localVideos.has('camera'))) { previewStream?.getTracks().forEach(track => track.stop()); return; } // Call directly from the click handler so screen capture retains user activation. const stream = source === 'camera' ? previewStream ?? await captureCallsCamera(deviceId, quality) - : await navigator.mediaDevices.getDisplayMedia({ video: videoConstraints(quality), audio: false }); + : await navigator.mediaDevices.getDisplayMedia({ video: videoConstraints(quality), audio: true }); const track = stream.getVideoTracks()[0]; - for (const extra of stream.getTracks()) if (extra !== track) extra.stop(); - if (this.isClosed() || this.peer == null) { track?.stop(); return; } - if (track == null) throw new DOMException('No video track', 'NotFoundError'); + const audio = source === 'screen' ? stream.getAudioTracks()[0] : undefined; + for (const extra of stream.getTracks()) if (extra !== track && extra !== audio) extra.stop(); + if (this.isClosed() || this.peer == null) { track?.stop(); audio?.stop(); return; } + if (track == null) { audio?.stop(); throw new DOMException('No video track', 'NotFoundError'); } track.contentHint = source === 'screen' ? 'detail' : 'motion'; - const video = { track }; - this.localVideos.set(source, video); - this.videoCallbacks?.localTrack(source, track); + const id = source === 'camera' ? 'camera' : crypto.randomUUID(); + const video: CallsLocalVideo = { source, track, audio: audio == null ? undefined : { track: audio } }; + this.localVideos.set(id, video); + audio?.addEventListener('ended', () => { + const media = video.audio; + if (this.localVideos.get(id) !== video || media == null || media.track !== audio) return; + video.audio = undefined; + media.track.stop(); + void this.enqueue(() => this.closePublishedTrack(media)).catch(error => this.videoCallbacks?.error(error)); + }, { once: true }); + this.videoCallbacks?.localTrack(source, track, id); track.addEventListener('ended', () => { - if (this.localVideos.get(source)?.track === track) void this.stopVideo(source).catch(error => this.videoCallbacks?.error(error)); + if (this.localVideos.get(id)?.track === track) void this.stopVideo(source, id).catch(error => this.videoCallbacks?.error(error)); }, { once: true }); try { - await this.enqueue(() => this.publishVideo(source, video)); + await this.enqueue(async () => { + await this.publishTrack(source, video); + if (video.audio != null && this.localVideos.get(id) === video) await this.publishTrack('screen', video.audio, video.publicationId); + }); } catch (error) { - await this.stopVideo(source).catch(() => undefined); + await this.stopVideo(source, id).catch(() => undefined); throw error; } } - private async publishVideo(source: CallsVideoSource, video: { track: MediaStreamTrack; publicationId?: string; transceiver?: RTCRtpTransceiver }): Promise { + private async publishTrack(source: CallsVideoSource, video: CallsLocalTrack, screenPublicationId?: string): Promise { const peer = this.peer; if (peer == null || this.isClosed() || video.track.readyState === 'ended') return; await this.ensureCredential(); @@ -409,8 +438,8 @@ export class CallsMediaController { video.transceiver = transceiver; try { // Keep screen/camera encoding consistent across browser and SFU renegotiations. - const codecs = RTCRtpSender.getCapabilities('video')?.codecs.filter(codec => ['video/vp8', 'video/rtx'].includes(codec.mimeType.toLowerCase())); - if (codecs?.some(codec => codec.mimeType.toLowerCase() === 'video/vp8') && typeof transceiver.setCodecPreferences === 'function') transceiver.setCodecPreferences(codecs); + const codecs = RTCRtpSender.getCapabilities(video.track.kind)?.codecs.filter(codec => (video.track.kind === 'audio' ? ['audio/opus'] : ['video/vp8', 'video/rtx']).includes(codec.mimeType.toLowerCase())); + if (codecs?.some(codec => codec.mimeType.toLowerCase() === (video.track.kind === 'audio' ? 'audio/opus' : 'video/vp8')) && typeof transceiver.setCodecPreferences === 'function') transceiver.setCodecPreferences(codecs); const offer = await peer.createOffer(); await peer.setLocalDescription(offer); await this.waitForIceGathering(peer); @@ -418,7 +447,7 @@ export class CallsMediaController { const result = await misskeyApi('calls/media/tracks/publish', { roomId: this.roomId, participantId: this.participantId!, connectionId: this.connectionId, generation: this.generation, operationId: crypto.randomUUID(), mediaCredential: this.mediaCredential!, - mediaSource: source, mid: transceiver.mid!, sessionDescription: { type: 'offer', sdp: peer.localDescription?.sdp ?? offer.sdp ?? '' }, + mediaSource: source, screenPublicationId, mid: transceiver.mid!, sessionDescription: { type: 'offer', sdp: peer.localDescription?.sdp ?? offer.sdp ?? '' }, }); video.publicationId = result.publicationId; this.publications.add(result.publicationId); @@ -431,11 +460,12 @@ export class CallsMediaController { } public async setVideoQuality(source: CallsVideoSource, quality: CallsVideoQuality): Promise { - const video = this.localVideos.get(source); - if (video == null || this.isClosed()) return; + if (this.isClosed()) return; await this.enqueue(async () => { - if (this.isClosed() || this.localVideos.get(source) !== video) return; - await video.track.applyConstraints(videoConstraints(quality)); + if (this.isClosed()) return; + for (const video of this.localVideos.values()) { + if (video.source === source) await video.track.applyConstraints(videoConstraints(quality)); + } }); } @@ -454,7 +484,7 @@ export class CallsMediaController { video.track.stop(); video.track = track; track.contentHint = 'motion'; - this.videoCallbacks?.localTrack('camera', track); + this.videoCallbacks?.localTrack('camera', track, 'camera'); track.addEventListener('ended', () => { if (video.track === track) void this.stopVideo('camera').catch(error => this.videoCallbacks?.error(error)); }, { once: true }); @@ -462,22 +492,33 @@ export class CallsMediaController { } catch (error) { track.stop(); throw error; } } - public async stopVideo(source: CallsVideoSource): Promise { - const video = this.localVideos.get(source); - if (video == null) return; - video.track.stop(); - this.localVideos.delete(source); - this.videoCallbacks?.localTrack(source, null); + public async stopVideo(source: CallsVideoSource, videoId?: string): Promise { + const videos = [...this.localVideos].filter(([id, video]) => video.source === source && (videoId == null || id === videoId)); + for (const [id, video] of videos) { + video.track.stop(); + video.audio?.track.stop(); + this.localVideos.delete(id); + this.videoCallbacks?.localTrack(source, null, id); + } await this.enqueue(async () => { - await video.transceiver?.sender.replaceTrack(null); - if (video.publicationId == null || this.isClosed()) return; - await this.ensureCredential(); - await misskeyApi('calls/media/tracks/close', { - roomId: this.roomId, participantId: this.participantId!, connectionId: this.connectionId, - generation: this.generation, operationId: crypto.randomUUID(), mediaCredential: this.mediaCredential!, publicationId: video.publicationId, - }); - this.publications.delete(video.publicationId); + for (const [, video] of videos) { + for (const media of [video.audio, video]) { + if (media == null) continue; + await this.closePublishedTrack(media); + } + } + }); + } + + private async closePublishedTrack(media: CallsLocalTrack): Promise { + await media.transceiver?.sender.replaceTrack(null); + if (media.publicationId == null || this.isClosed()) return; + await this.ensureCredential(); + await misskeyApi('calls/media/tracks/close', { + roomId: this.roomId, participantId: this.participantId!, connectionId: this.connectionId, + generation: this.generation, operationId: crypto.randomUUID(), mediaCredential: this.mediaCredential!, publicationId: media.publicationId, }); + this.publications.delete(media.publicationId); } public setMuted(muted: boolean): void { @@ -644,9 +685,10 @@ export class CallsMediaController { this.remotePublications.clear(); this.receivedRemoteTracks.clear(); if (stopTrack) { - for (const [source, video] of this.localVideos) { + for (const [id, video] of this.localVideos) { video.track.stop(); - this.videoCallbacks?.localTrack(source, null); + video.audio?.track.stop(); + this.videoCallbacks?.localTrack(video.source, null, id); } this.localVideos.clear(); } diff --git a/packages/frontend/src/utility/calls-session.ts b/packages/frontend/src/utility/calls-session.ts index 66a09b7a188..61a781b5e16 100644 --- a/packages/frontend/src/utility/calls-session.ts +++ b/packages/frontend/src/utility/calls-session.ts @@ -64,15 +64,17 @@ const reconnectCandidate = ref(null); const reconnectRoomState = ref<'checking' | 'open' | 'unavailable'>('checking'); const reconnectSecondsRemaining = ref(0); const speakerRequestResult = ref<'rejected' | null>(null); -const remoteAudio = new Map(); +const remoteAudio = new Map(); const participantVolumes = shallowRef(new Map()); -const localVideos = shallowRef(new Map()); +const localVideos = shallowRef(new Map()); +const screenVolumes = shallowRef(new Map()); +const screenAudioIds = shallowRef(new Set()); const remoteVideos = shallowRef(new Map()); const videoBusy = ref(false); const videoQuality = ref>({ camera: { height: 720, frameRate: 30 }, screen: { height: 1080, frameRate: 30 } }); const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function'; const videos = computed(() => [ - ...[...localVideos.value].map(([source, stream]) => ({ id: `local-${source}`, participantId: myParticipant.value?.id ?? '', source, stream, local: true })), + ...[...localVideos.value].map(([id, stream]) => ({ id: `local-${id}`, participantId: myParticipant.value?.id ?? '', source: id === 'camera' ? 'camera' as const : 'screen' as const, stream, local: true })), ...[...remoteVideos.value].map(([id, video]) => ({ id, ...video, local: false })), ]); @@ -172,7 +174,7 @@ const canShareScreenMedia = computed(() => isSpeaker.value && $i?.policies.canJo const controls = computed(() => ({ role: myParticipant.value?.role ?? 'listener', canSpeak: isSpeaker.value && canSpeak.value, canPublishVideo: canPublishVideo.value, canShareScreen: canShareScreenMedia.value, - muted: muted.value, cameraOn: localVideos.value.has('camera'), screenOn: localVideos.value.has('screen'), + muted: muted.value, cameraOn: localVideos.value.has('camera'), screenOn: [...localVideos.value.keys()].some(id => id !== 'camera'), status: mediaState.value, busy: videoBusy.value, joining: joining.value, screenSupported: canShareScreen, speakerRequestEnabled: canSpeak.value && room.value?.mode === 'stage', speakerRequested: myParticipant.value?.speakerRequestedAt != null, @@ -191,6 +193,7 @@ function removeRemoteTrack(publicationId: string): void { const audio = remoteAudio.get(publicationId)?.element; if (audio != null) { audio.pause(); audio.srcObject = null; audio.remove(); } remoteAudio.delete(publicationId); + screenAudioIds.value = new Set([...remoteAudio.values()].flatMap(item => item.screenPublicationId == null ? [] : [item.screenPublicationId])); const next = new Map(remoteVideos.value); next.delete(publicationId); remoteVideos.value = next; @@ -201,12 +204,21 @@ function getParticipantVolume(userId: string): number { } function applyParticipantVolumes(): void { - for (const { participantId, element } of remoteAudio.values()) { + for (const { participantId, element, screenPublicationId } of remoteAudio.values()) { const participant = participants.value.find(item => item.id === participantId); - element.volume = participant == null ? 1 : getParticipantVolume(participant.userId) / 100; + element.volume = screenPublicationId != null ? getScreenVolume(screenPublicationId) / 100 : participant == null ? 1 : getParticipantVolume(participant.userId) / 100; } } +function getScreenVolume(publicationId: string): number { + return screenVolumes.value.get(publicationId) ?? 100; +} + +function setScreenVolume(publicationId: string, volume: number): void { + screenVolumes.value = new Map(screenVolumes.value).set(publicationId, Math.max(0, Math.min(100, volume))); + applyParticipantVolumes(); +} + function setParticipantVolume(userId: string, volume: number): void { participantVolumes.value = new Map(participantVolumes.value).set(userId, Math.max(0, Math.min(100, volume))); applyParticipantVolumes(); @@ -224,7 +236,8 @@ function addRemoteTrack(track: MediaStreamTrack, publication: CallsRemotePublica audio.autoplay = true; audio.hidden = true; audio.srcObject = new MediaStream([track]); - remoteAudio.set(publication.id, { participantId: publication.participantId, element: audio }); + remoteAudio.set(publication.id, { participantId: publication.participantId, element: audio, screenPublicationId: publication.screenPublicationId }); + if (publication.screenPublicationId != null) screenAudioIds.value = new Set(screenAudioIds.value).add(publication.screenPublicationId); applyParticipantVolumes(); window.document.body.append(audio); void audio.play().catch(() => { needsAudioResume.value = true; }); @@ -272,11 +285,11 @@ async function connectMedia(generation: number, previousConnection?: { connectio noiseSuppressionChanged(enabled) { if (generation === sessionGeneration && media.value === controller) noiseSuppression.value = enabled; }, - localTrack(source, track) { + localTrack(_source, track, id) { if (generation !== sessionGeneration || media.value !== controller) return; const next = new Map(localVideos.value); - if (track == null) next.delete(source); - else next.set(source, new MediaStream([track])); + if (track == null) next.delete(id); + else next.set(id, new MediaStream([track])); localVideos.value = next; }, remoteRemoved: removeRemoteTrack, @@ -309,6 +322,8 @@ async function clearSession(): Promise { cancelCameraPreview?.(); clearCallsScreenWindows(); sessionGeneration += 1; + screenVolumes.value = new Map(); + screenAudioIds.value = new Set(); const controller = media.value; media.value = null; disposeConnection(); @@ -524,13 +539,14 @@ async function switchMicrophone(deviceId: string): Promise { await media.value?.switchMicrophone(deviceId); } -async function toggleVideo(source: CallsVideoSource): Promise { +async function toggleVideo(source: CallsVideoSource, addScreen = false, videoId?: string): Promise { const controller = media.value; if (!isSpeaker.value || joining.value || videoBusy.value || mediaState.value !== 'connected' || controller == null) return; - if (!localVideos.value.has(source) && !(source === 'camera' ? canPublishVideo.value : canShareScreenMedia.value)) return; + const active = source === 'camera' ? localVideos.value.has('camera') : [...localVideos.value.keys()].some(id => id !== 'camera'); + if ((addScreen || !active) && !(source === 'camera' ? canPublishVideo.value : canShareScreenMedia.value)) return; videoBusy.value = true; try { - if (localVideos.value.has(source)) await controller.stopVideo(source); + if (!addScreen && active) await controller.stopVideo(source, videoId); else if (source === 'camera') { const stream = await captureCallsCamera(selectedCamera.value || undefined, videoQuality.value.camera); let published = false; @@ -639,7 +655,15 @@ function videoQualityMenu(source: CallsVideoSource): MenuItem[] { function openScreenSettings(event: MouseEvent): void { if (joining.value || videoBusy.value || !canShareScreenMedia.value) return; - popupMenu(videoQualityMenu('screen'), event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined); + popupMenu([ + ...videoQualityMenu('screen'), + { type: 'divider' }, + { text: i18n.ts._calls.addScreenSharing, icon: 'ti ti-plus', action: () => toggleVideo('screen', true) }, + ...[...localVideos.value].filter(([id]) => id !== 'camera').map(([id, stream], index) => ({ + text: `${i18n.ts._calls.stopScreenSharing} · ${stream.getVideoTracks()[0]?.label || index + 1}`, + icon: 'ti ti-screen-share-off', action: () => toggleVideo('screen', false, id), + })), + ], event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined); } async function resumeAudio(): Promise { @@ -738,6 +762,9 @@ export function useCallsSession() { toggleVideo, selectedMicrophone, microphones, + screenAudioIds, + getScreenVolume, + setScreenVolume, needsAudioResume, reconnectCandidate, reconnectRoomState, diff --git a/packages/frontend/test/unit/calls-media-controller.test.ts b/packages/frontend/test/unit/calls-media-controller.test.ts index 649eade66db..7848b383e4b 100644 --- a/packages/frontend/test/unit/calls-media-controller.test.ts +++ b/packages/frontend/test/unit/calls-media-controller.test.ts @@ -185,7 +185,7 @@ describe('CallsMediaController', () => { expect(apiMock).toHaveBeenCalledWith('calls/media/tracks/publish', expect.objectContaining({ mediaSource: 'screen', mid: '2' })); screen.dispatchEvent(new Event('ended')); await vi.waitFor(() => expect(apiMock).toHaveBeenCalledWith('calls/media/tracks/close', expect.anything())); - expect(localTrack).toHaveBeenCalledWith('screen', null); + expect(localTrack).toHaveBeenCalledWith('screen', null, expect.any(String)); expect(camera.stop).not.toHaveBeenCalled(); expect(audio.stop).not.toHaveBeenCalled(); await controller.close(); @@ -193,6 +193,42 @@ describe('CallsMediaController', () => { expect(audio.stop).toHaveBeenCalled(); }); + test('publishes two screen shares with independent audio and stops only the selected share', async () => { + const microphone = makeTrack('audio'); + const screens = [makeTrack('video'), makeTrack('video')]; + const audios = [makeTrack('audio'), makeTrack('audio')]; + const getDisplayMedia = vi.fn(); + for (let index = 0; index < screens.length; index++) { + getDisplayMedia.mockResolvedValueOnce({ getVideoTracks: () => [screens[index]], getAudioTracks: () => [audios[index]], getTracks: () => [screens[index], audios[index]] }); + } + installBrowserMedia(vi.fn().mockResolvedValue(stream(microphone)), getDisplayMedia); + const localTrack = vi.fn(); + const controller = new CallsMediaController('room-a', 'speaker', undefined, undefined, undefined, undefined, false, { localTrack, remoteRemoved: vi.fn(), error: vi.fn() }); + await controller.connect(); + await controller.startVideo('screen'); + await controller.startVideo('screen'); + expect(getDisplayMedia).toHaveBeenCalledTimes(2); + expect(getDisplayMedia).toHaveBeenCalledWith(expect.objectContaining({ audio: true })); + const publications = apiMock.mock.calls.filter(([endpoint]) => endpoint === 'calls/media/tracks/publish'); + expect(publications.slice(1).map(([, input]) => input.mid)).toEqual(['1', '2', '3', '4']); + expect(publications[2][1].screenPublicationId).toBeDefined(); + expect(publications[4][1].screenPublicationId).toBeDefined(); + expect(publications[2][1].screenPublicationId).not.toBe(publications[4][1].screenPublicationId); + await controller.setVideoQuality('screen', { height: 1080, frameRate: 60 }); + for (const screen of screens) expect(screen.applyConstraints).toHaveBeenCalledOnce(); + screens[0].dispatchEvent(new Event('ended')); + await vi.waitFor(() => expect(apiMock.mock.calls.filter(([endpoint]) => endpoint === 'calls/media/tracks/close')).toHaveLength(2)); + expect(audios[0].stop).toHaveBeenCalledOnce(); + expect(screens[1].stop).not.toHaveBeenCalled(); + expect(audios[1].stop).not.toHaveBeenCalled(); + expect(microphone.stop).not.toHaveBeenCalled(); + audios[1].dispatchEvent(new Event('ended')); + await vi.waitFor(() => expect(apiMock.mock.calls.filter(([endpoint]) => endpoint === 'calls/media/tracks/close')).toHaveLength(3)); + expect(screens[1].stop).not.toHaveBeenCalled(); + await controller.close(); + expect(audios[1].stop).toHaveBeenCalledOnce(); + }); + test.each(['camera', 'screen'] as const)('stops local %s capture when moderation removes its publication', async source => { const audio = makeTrack('audio'); const camera = makeTrack('video'); @@ -213,7 +249,7 @@ describe('CallsMediaController', () => { expect((source === 'camera' ? camera : screen).stop).toHaveBeenCalledOnce(); expect((source === 'camera' ? screen : camera).stop).not.toHaveBeenCalled(); expect(audio.stop).not.toHaveBeenCalled(); - expect(localTrack).toHaveBeenCalledWith(source, null); + expect(localTrack).toHaveBeenCalledWith(source, null, expect.any(String)); expect(apiMock).not.toHaveBeenCalledWith('calls/media/tracks/close', expect.anything()); await controller.close(); }); @@ -391,7 +427,7 @@ describe('CallsMediaController', () => { expect(FakePeerConnection.instances[0].transceivers[1].sender.track).toBe(replacement); expect(camera.stop).toHaveBeenCalled(); expect(audio.stop).not.toHaveBeenCalled(); - expect(localTrack).toHaveBeenLastCalledWith('camera', replacement); + expect(localTrack).toHaveBeenLastCalledWith('camera', replacement, 'camera'); expect(apiMock.mock.calls.filter(([endpoint]) => endpoint === 'calls/media/tracks/publish')).toHaveLength(2); expect(getUserMedia).toHaveBeenLastCalledWith(expect.objectContaining({ video: expect.objectContaining({ deviceId: { exact: 'camera-b' } }), audio: false })); await controller.close(); diff --git a/packages/frontend/test/unit/calls-session.test.ts b/packages/frontend/test/unit/calls-session.test.ts index e18716bd5d8..9e0e69c962f 100644 --- a/packages/frontend/test/unit/calls-session.test.ts +++ b/packages/frontend/test/unit/calls-session.test.ts @@ -27,7 +27,7 @@ const fixture = vi.hoisted(() => ({ revoked: [] as Array<(event: { reason: string; connectionId?: string; generation?: number }) => void>, connections: [] as Array<{ room: { value: { id: string; title: string; state: string; revision: number } }; endReason: { value: 'host-timeout' | null }; participants: { value: Array<{ id: string; userId: string; role: string; isMuted: boolean; joinedAt?: string }> }; refresh: ReturnType }>, remoteTrackCallbacks: [] as Array<(track: MediaStreamTrack, publication: CallsRemotePublication) => void>, - controllers: [] as Array<{ connectionIdentity: { connectionId: string; generation: number }; replaceExisting: boolean; localTrack: { enabled: boolean } | null; setMuted: ReturnType; switchMicrophone: ReturnType; close: ReturnType; startVideo: ReturnType; setVideoQuality: ReturnType; setNoiseSuppression: ReturnType }>, + controllers: [] as Array<{ connectionIdentity: { connectionId: string; generation: number }; replaceExisting: boolean; localTrack: { enabled: boolean } | null; setMuted: ReturnType; switchMicrophone: ReturnType; close: ReturnType; startVideo: ReturnType; stopVideo: ReturnType; setVideoQuality: ReturnType; setNoiseSuppression: ReturnType }>, })); vi.mock('@/utility/sound.js', () => ({ playMisskeySfx: fixture.playSound })); @@ -68,6 +68,7 @@ vi.mock('@/utility/calls-media.js', () => ({ public localTrack = fixture.microphoneAvailable ? { enabled: true } : null; public switchMicrophone = vi.fn(async () => { this.localTrack = { enabled: false }; }); public startVideo = vi.fn().mockResolvedValue(undefined); + public stopVideo = vi.fn().mockResolvedValue(undefined); public close = vi.fn().mockResolvedValue(undefined); public setVideoQuality = vi.fn().mockResolvedValue(undefined); public setNoiseSuppression = vi.fn(); @@ -337,6 +338,57 @@ describe('Calls session device handoff', () => { } }); + test('sets each screen audio volume independently from the microphone and cleans up on leave', async () => { + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(); + const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); + try { + await session.join('room-a', false); + session.participants.value.push({ ...session.participants.value[0], id: 'participant-b', userId: 'user-b' }); + const receive = (id: string, screenPublicationId?: string) => { + const track = Object.assign(new EventTarget(), { kind: 'audio' }) as MediaStreamTrack; + fixture.remoteTrackCallbacks[0](track, { id, participantId: 'participant-b', mediaKind: 'audio', mediaSource: screenPublicationId == null ? 'microphone' : 'screen', screenPublicationId }); + return { track, audio: document.querySelectorAll('audio')[document.querySelectorAll('audio').length - 1] }; + }; + const microphone = receive('microphone'); + const first = receive('audio-a', 'screen-a'); + const second = receive('audio-b', 'screen-b'); + expect(session.screenAudioIds.value).toEqual(new Set(['screen-a', 'screen-b'])); + session.setScreenVolume('screen-a', 25); + session.setScreenVolume('screen-b', 75); + session.setParticipantVolume('user-b', 50); + expect(first.audio.volume).toBe(0.25); + expect(second.audio.volume).toBe(0.75); + expect(microphone.audio.volume).toBe(0.5); + const replacement = receive('audio-a', 'screen-a'); + expect(replacement.audio.volume).toBe(0.25); + replacement.track.dispatchEvent(new Event('ended')); + expect(session.screenAudioIds.value).toEqual(new Set(['screen-b'])); + expect(second.audio.isConnected).toBe(true); + await session.leave(); + expect(document.querySelector('audio')).toBeNull(); + expect(session.getScreenVolume('screen-a')).toBe(100); + expect(session.screenAudioIds.value.size).toBe(0); + } finally { + play.mockRestore(); + pause.mockRestore(); + } + }); + + test('adds and stops individual screen shares from settings while other shares remain active', async () => { + fixture.role = 'host'; + await session.join('room-a', true); + const stream = { getVideoTracks: () => [{ label: 'Window A' }] } as MediaStream; + session.localVideos.value = new Map([['screen-a', stream], ['screen-b', stream]]); + expect(session.controls.value.screenOn).toBe(true); + session.openScreenSettings(new MouseEvent('click')); + const menu = fixture.popupMenu.mock.calls[0][0]; + await menu[3].action(); + expect(fixture.controllers[0].startVideo).toHaveBeenCalledWith('screen', undefined, { height: 1080, frameRate: 30 }); + expect(fixture.controllers[0].stopVideo).not.toHaveBeenCalled(); + await menu[4].action(); + expect(fixture.controllers[0].stopVideo).toHaveBeenCalledWith('screen', 'screen-a'); + }); + test('sends a heartbeat with the current connection every 30 seconds and stops after leaving', async () => { await session.join('room-a', false); await vi.advanceTimersByTimeAsync(29_999); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 7b4d5c48350..0f00f62afb7 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -10480,6 +10480,14 @@ export interface Locale extends ILocale { * 画面を共有 */ "startScreenSharing": string; + /** + * 画面共有を追加 + */ + "addScreenSharing": string; + /** + * 画面共有の音量 + */ + "screenAudioVolume": string; /** * 共有を停止 */ diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 48f4f653e56..088ad1b4dde 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -16946,6 +16946,8 @@ export interface operations { participantId: string; /** @enum {string} */ mediaSource: 'microphone' | 'camera' | 'screen'; + /** Format: id */ + screenPublicationId?: string; /** @enum {string} */ mediaKind: 'audio' | 'video'; }[]; @@ -17306,6 +17308,8 @@ export interface operations { * @enum {string} */ mediaSource?: 'microphone' | 'camera' | 'screen'; + /** Format: misskey:id */ + screenPublicationId?: string; mid: string; sessionDescription: { /** @enum {string} */