From 85ffea1905b195fea91aab1364745fa994ef7fa0 Mon Sep 17 00:00:00 2001 From: Benjamin Fernandez Date: Fri, 5 Jun 2026 16:13:59 +0200 Subject: [PATCH 1/4] feat: Add recording_layout server config option for Talk recording layout Add configurable recording layout setting (occ config:app:set spreed recording_layout --value=grid) to control the call layout used during recording. Defaults to 'speaker' (existing behavior). - Config.php: add getRecordingLayout() method with validation - Capabilities.php: expose 'recording-layout' in call config - ResponseDefinitions.php: document recording-layout type - CapabilitiesTest.php: add recording-layout to test expectations - docs/capabilities.md: document the new capability under section 24 Co-authored-by: opencode Signed-off-by: Benjamin Fernandez --- docs/capabilities.md | 1 + lib/Capabilities.php | 2 ++ lib/Config.php | 5 +++++ lib/ResponseDefinitions.php | 2 ++ tests/php/CapabilitiesTest.php | 2 ++ 5 files changed, 12 insertions(+) diff --git a/docs/capabilities.md b/docs/capabilities.md index cae4f691aa3..9a29435628e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -220,6 +220,7 @@ * `config => call => play-sounds` (local) - Whether the user has sounds enabled for calls (falls back to admin default for guests) * `config => call => grid-limit` (local) - Suggested gird size for all participants * `config => call => grid-limit-enforced` (local) - Whether the limit is hard enforced for all participants +* `config => call => recording-layout` (local) - Recording layout style, either `grid` or `speaker` (defaults to `speaker`) * `config => feature-hints => current` (local) - The current feature hint count that should be sent to the app config to hide all current feature hints * `config => feature-hints => hidden` (local) - Number of the last hint the administration has hidden via the app config * `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a3f3cbbf2e5..ebc4fcc5294 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -184,6 +184,7 @@ class Capabilities implements IPublicCapability { 'play-sounds', 'grid-limit', 'grid-limit-enforced', + 'recording-layout', ], 'chat' => [ 'read-privacy', @@ -286,6 +287,7 @@ public function getCapabilities(): array { 'play-sounds' => $this->talkConfig->getPlaySoundsForUser($user), 'grid-limit' => $this->talkConfig->getGridVideosLimit(), 'grid-limit-enforced' => $this->talkConfig->getGridVideosLimitEnforced(), + 'recording-layout' => $this->talkConfig->getRecordingLayout(), ], 'chat' => [ 'max-length' => ChatManager::MAX_CHAT_LENGTH, diff --git a/lib/Config.php b/lib/Config.php index e6c3b78a58c..f28f8ab35d3 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -255,6 +255,11 @@ public function getRecordingFolder(string $userId): string { ); } + public function getRecordingLayout(): string { + $value = $this->config->getAppValue('spreed', 'recording_layout', 'speaker'); + return in_array($value, ['grid', 'speaker'], true) ? $value : 'speaker'; + } + public function getLiveTranscriptionTargetLanguageId(?string $userId = null): string { return $this->config->getUserValue( $userId, diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 5c8ed5e407b..8bdb7aa4e75 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -806,6 +806,8 @@ * grid-limit: int, * // Whether the grid limit is enforced by the server * grid-limit-enforced: bool, + * // Recording layout ('grid' or 'speaker') + * recording-layout: string, * }, * chat: array{ * // Maximum length of a chat message diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index d8a6dad6649..27c622df5b4 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -187,6 +187,7 @@ public function testGetCapabilitiesGuest(): void { 'play-sounds' => false, 'grid-limit' => 0, 'grid-limit-enforced' => false, + 'recording-layout' => 'speaker', 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', @@ -414,6 +415,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'play-sounds' => false, 'grid-limit' => 0, 'grid-limit-enforced' => false, + 'recording-layout' => 'speaker', 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', From c84af84ae297a2a432f8924bb3dd05d4ad13a242 Mon Sep 17 00:00:00 2001 From: Benjamin Fernandez Date: Fri, 5 Jun 2026 16:14:01 +0200 Subject: [PATCH 2/4] chore: Regenerate OpenAPI types for recording-layout capability Co-authored-by: opencode Signed-off-by: Benjamin Fernandez --- openapi-administration.json | 7 ++++++- openapi-backend-recording.json | 7 ++++++- openapi-backend-signaling.json | 7 ++++++- openapi-backend-sipbridge.json | 7 ++++++- openapi-bots.json | 7 ++++++- openapi-federation.json | 7 ++++++- openapi-full.json | 7 ++++++- openapi.json | 7 ++++++- src/__mocks__/capabilities.ts | 1 + src/types/openapi/openapi-administration.ts | 2 ++ src/types/openapi/openapi-backend-recording.ts | 2 ++ src/types/openapi/openapi-backend-signaling.ts | 2 ++ src/types/openapi/openapi-backend-sipbridge.ts | 2 ++ src/types/openapi/openapi-bots.ts | 2 ++ src/types/openapi/openapi-federation.ts | 2 ++ src/types/openapi/openapi-full.ts | 2 ++ src/types/openapi/openapi.ts | 2 ++ 17 files changed, 65 insertions(+), 8 deletions(-) diff --git a/openapi-administration.json b/openapi-administration.json index ad991cc2d39..a57d2dfc7f2 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -183,7 +183,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -285,6 +286,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index ea24c914bbc..06ff7105a78 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -106,7 +106,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -208,6 +209,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index c27f3a08b32..f14afae1146 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -106,7 +106,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -208,6 +209,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 2c319bbf544..a8fc09209be 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -157,7 +157,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -259,6 +260,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-bots.json b/openapi-bots.json index e4bdc480ba3..ca3ee67a0ef 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -106,7 +106,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -208,6 +209,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 7b254a49b81..3f53bb1f30f 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -157,7 +157,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -259,6 +260,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi-full.json b/openapi-full.json index 3cddb5c40ab..c6fec5127b0 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -340,7 +340,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -442,6 +443,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/openapi.json b/openapi.json index 88a70a97ff4..888060c3930 100644 --- a/openapi.json +++ b/openapi.json @@ -293,7 +293,8 @@ "live-transcription-target-language-id", "play-sounds", "grid-limit", - "grid-limit-enforced" + "grid-limit-enforced", + "recording-layout" ], "properties": { "enabled": { @@ -395,6 +396,10 @@ "grid-limit-enforced": { "type": "boolean", "description": "Whether the grid limit is enforced by the server" + }, + "recording-layout": { + "type": "string", + "description": "Recording layout ('grid' or 'speaker')" } } }, diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 2d57772521f..831c03d1bbd 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -168,6 +168,7 @@ export const mockedCapabilities: Capabilities = { 'play-sounds': true, 'grid-limit': 0, 'grid-limit-enforced': false, + 'recording-layout': 'speaker', }, chat: { 'max-length': 32000, diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index a1dc174f13d..bf77f8e1a48 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -296,6 +296,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index b2dfdcb2e89..188fac4c58e 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -110,6 +110,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index e9af4bf678a..1d1876d9915 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -96,6 +96,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index f8905e43fab..a7896e6b482 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -221,6 +221,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index 0d0dbb40f04..60cb106df7f 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -114,6 +114,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index b2efabea4f6..5d9217536eb 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -232,6 +232,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 886c5f18953..3bc7c77aa4b 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2751,6 +2751,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 29e96eaf5bb..f9908b039be 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -2217,6 +2217,8 @@ export type components = { "grid-limit": number; /** @description Whether the grid limit is enforced by the server */ "grid-limit-enforced": boolean; + /** @description Recording layout ('grid' or 'speaker') */ + "recording-layout": string; }; chat: { /** From 1ed795a7d4de23da9c2840db1696c620c9e62056 Mon Sep 17 00:00:00 2001 From: Benjamin Fernandez Date: Fri, 5 Jun 2026 16:14:09 +0200 Subject: [PATCH 3/4] feat: Use configurable recording layout in recording frontend RecordingApp.vue reads 'recording-layout' from server capabilities and sets the call view mode accordingly (grid or speaker). VideosGrid.vue uses full slot count in recording mode since local video is hidden. Co-authored-by: opencode Signed-off-by: Benjamin Fernandez --- src/RecordingApp.vue | 5 +++++ src/components/CallView/Grid/VideosGrid.vue | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/RecordingApp.vue b/src/RecordingApp.vue index f7bada6859f..f34ee8eb069 100644 --- a/src/RecordingApp.vue +++ b/src/RecordingApp.vue @@ -8,7 +8,9 @@ import { onBeforeMount } from 'vue' import { useRoute, useRouter } from 'vue-router' import CallView from './components/CallView/CallView.vue' import { useGetToken } from './composables/useGetToken.ts' +import { getTalkConfig } from './services/CapabilitiesManager.ts' import SessionStorage from './services/SessionStorage.js' +import { useCallViewStore } from './stores/callView.ts' import { useSoundsStore } from './stores/sounds.js' import { useTokenStore } from './stores/token.ts' import { signalingKill } from './utils/webrtc/index.js' @@ -19,12 +21,15 @@ const route = useRoute() const soundsStore = useSoundsStore() const token = useGetToken() const tokenStore = useTokenStore() +const callViewStore = useCallViewStore() onBeforeMount(async () => { await router.isReady() if (route.name === 'recording') { tokenStore.updateToken(route.params.token as string) await soundsStore.setShouldPlaySounds(false) + const isGridLayout = getTalkConfig(token.value, 'call', 'recording-layout') === 'grid' + callViewStore.setCallViewMode({ token: token.value, isGrid: isGridLayout, isStripeOpen: false }) } // This should not be strictly needed, as the recording server is diff --git a/src/components/CallView/Grid/VideosGrid.vue b/src/components/CallView/Grid/VideosGrid.vue index b740dc3f468..b83fe605e4c 100644 --- a/src/components/CallView/Grid/VideosGrid.vue +++ b/src/components/CallView/Grid/VideosGrid.vue @@ -457,9 +457,13 @@ export default { // Number of grid slots at any given moment // The local video always takes one slot if the grid view is not shown - // as a stripe. + // as a stripe. In recording mode the local video is not shown, so all + // slots are available for remote participants. slots() { - return this.isStripe ? this.rows * this.columns : this.rows * this.columns - 1 + if (this.isStripe || this.isRecording) { + return this.rows * this.columns + } + return this.rows * this.columns - 1 }, // Grid pages at any given moment @@ -776,7 +780,7 @@ export default { let currentColumns = this.columns let currentRows = this.rows - let currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1 + let currentSlots = this.isStripe || this.isRecording ? currentColumns * currentRows : currentColumns * currentRows - 1 // Run this code only if we don't have an 'overflow' of videos. If the // videos are populating the grid, there's no point in shrinking it. @@ -809,7 +813,7 @@ export default { currentColumns-- } - currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1 + currentSlots = this.isStripe || this.isRecording ? currentColumns * currentRows : currentColumns * currentRows - 1 // Check that there are still enough slots available if (numberOfVideos > currentSlots) { @@ -822,7 +826,7 @@ export default { currentRows-- } - currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1 + currentSlots = this.isStripe || this.isRecording ? currentColumns * currentRows : currentColumns * currentRows - 1 // Check that there are still enough slots available if (numberOfVideos > currentSlots) { From aff5dfdfa1add62d878936f35081363f239e714b Mon Sep 17 00:00:00 2001 From: Benjamin Fernandez Date: Fri, 5 Jun 2026 17:15:14 +0200 Subject: [PATCH 4/4] fix: Align recording-layout tests and mocks Add missing Config mock return value for recording-layout in CapabilitiesTest and mark recording-layout as local in mocked capabilities. Co-authored-by: opencode Signed-off-by: Benjamin Fernandez --- src/__mocks__/capabilities.ts | 1 + tests/php/CapabilitiesTest.php | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 831c03d1bbd..17ca7631ac8 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -230,6 +230,7 @@ export const mockedCapabilities: Capabilities = { 'can-upload-background', 'start-without-media', 'blur-virtual-background', + 'recording-layout', ], chat: [ 'read-privacy', diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 27c622df5b4..68dfdeb1af3 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -132,6 +132,10 @@ public function testGetCapabilitiesGuest(): void { ->with(null) ->willReturn(''); + $this->talkConfig->expects($this->any()) + ->method('getRecordingLayout') + ->willReturn('speaker'); + $this->serverConfig->expects($this->any()) ->method('getAppValue') ->willReturnMap([ @@ -338,6 +342,10 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea ->method('getDefaultPermissions') ->willReturn(502); + $this->talkConfig->expects($this->any()) + ->method('getRecordingLayout') + ->willReturn('speaker'); + $user->method('getQuota') ->willReturn($quota);