UBERF-9731 Recorder initial version (#8800)

* UBERF-9731 Recorder initial version

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* fix: rush validate issues

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

---------

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2025-05-01 23:46:35 +07:00
committed by GitHub
parent 3c4057ed89
commit 8a9e04b8d4
58 changed files with 1679 additions and 547 deletions
+79 -9
View File
@@ -55,6 +55,9 @@ importers:
'@hcengineering/communication-yaml':
specifier: 0.1.172
version: 0.1.172(typescript@5.3.3)
'@hcengineering/drive':
specifier: ^0.6.32
version: 0.6.490
'@hocuspocus/provider':
specifier: ^2.15.2
version: 2.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23)
@@ -2083,9 +2086,6 @@ importers:
snappyjs:
specifier: ^0.7.0
version: 0.7.0
stream-browserify:
specifier: ^3.0.0
version: 3.0.0
style-loader:
specifier: ^3.3.1
version: 3.3.4(webpack@5.97.1)
@@ -2985,6 +2985,9 @@ packages:
'@hcengineering/account-client@0.7.28':
resolution: {integrity: sha512-k1dkioX+jYrLyAXLLLsBYVCdZKf3WjhnDHw/yeAQgLQQ/9W2ffLtDMZ8lYWV3XMQ3eWrUCodtTDOiAQgXd/LEQ==, tarball: https://npm.pkg.github.com/download/@hcengineering/account-client/0.7.28/32c9bc0dd297f00f4d5461467f25e072d01b9c76}
'@hcengineering/analytics@0.6.490':
resolution: {integrity: sha512-Jcdj9LXiIUxm75jRIaOrNlvTW48bd59ul2I0WB5NE3WHfAXKmk5FIBUd2dtTDhHMQUOZwUOKzVcxfYWIUs5HFQ==, tarball: https://npm.pkg.github.com/download/@hcengineering/analytics/0.6.490/e2ed2bc32ca04e170e2907e6b0c44a3b725d36bd}
'@hcengineering/analytics@0.7.28':
resolution: {integrity: sha512-+CI/wJ8zgop+3mEcvhlB1UJBU6tIKq2Y7qTichZjGli8BckEIEwbD4j2beu2qXRSoTT2yUGWM9zpS9+fmH/DAw==, tarball: https://npm.pkg.github.com/download/@hcengineering/analytics/0.7.28/a8faf3d575bb28b9682b659495e738f8e0ca596e}
@@ -3036,12 +3039,24 @@ packages:
peerDependencies:
typescript: ^5.6.3
'@hcengineering/core@0.6.490':
resolution: {integrity: sha512-BnYeOeSBxjYcwAwGcXcTcmLbNo0ft2Pt7QoDlHV4KLuDCQkU0Xw6hUvDgMyz6irHKciCa0jBdZ2vOgvBTTe3ug==, tarball: https://npm.pkg.github.com/download/@hcengineering/core/0.6.490/562815e33c0dca6833a43d71745ebb12fa6e7cb9}
'@hcengineering/core@0.7.28':
resolution: {integrity: sha512-qv4QE7mkect6JYlGMljW5CQ2uU80hFGq0E17lZ5rO79h1boWEKLJpINv+w3J2BdR/nNG7taIsdZg3yfl9fY4bg==, tarball: https://npm.pkg.github.com/download/@hcengineering/core/0.7.28/43b84c63262e2b8fe5554c1967812e2ce6bb56e1}
'@hcengineering/drive@0.6.490':
resolution: {integrity: sha512-UCcs/SQeXSP/PinDmyh/F6qMW3Ji4CI1nQYCLsxcMei6XHwXyMqfVppeqKpbmGVV+5HJOjAsbSaJe2EiLy6l5Q==, tarball: https://npm.pkg.github.com/download/@hcengineering/drive/0.6.490/270638c5412bb3cf2ac5f2a207896b069f87adb9}
'@hcengineering/platform@0.6.490':
resolution: {integrity: sha512-jtCHMznMpbPJQaX2RTbKN0tR5e2YgSrIYWHREXgi8VIAIygMLNtzkkNkVX/2FgcOHK+Gs1aF3mf4gsikiKyyKw==, tarball: https://npm.pkg.github.com/download/@hcengineering/platform/0.6.490/45e5e6cbd7fcb12cf241c5ace3c027689052f454}
'@hcengineering/platform@0.7.28':
resolution: {integrity: sha512-0zEifwsLdOs/M6TZ9xnPrQTTlhwW28sooiyL4ZfQsiC2AJUy05vLP5/W8V9eDLta+EiR5bjtlulGsg1GfPjUoQ==, tarball: https://npm.pkg.github.com/download/@hcengineering/platform/0.7.28/2126ff97284b679ceab3c86f24febd73a3a28712}
'@hcengineering/preference@0.6.490':
resolution: {integrity: sha512-gwX9U1T2H3A/yDoT2DLFn4SPD9Ki1mQ4bp3MhbPc6uc/cv8Dm2eD/nFM1viT2j03TrM8caKIpB0epONH3qj12g==, tarball: https://npm.pkg.github.com/download/@hcengineering/preference/0.6.490/9e1a4219a1cbec866faf43f7584abcda8fb9b6c2}
'@hcengineering/preference@0.7.28':
resolution: {integrity: sha512-WwwOfjZ1TLdTfPhQGvSjo8dS63HyuE7BDVhfstxP8D+H4BI5UKYoev/6vlwQf/3d+jI1S4f2ivO//IDJAD7sGg==, tarball: https://npm.pkg.github.com/download/@hcengineering/preference/0.7.28/f1739991cc012d194fa18f99565ae5bd64f0ad07}
@@ -3051,9 +3066,15 @@ packages:
'@hcengineering/tags@0.7.28':
resolution: {integrity: sha512-2KCVUu5N/GAkDsDRbdUi7gnBq4ze1Z3eCLoc8nz2G/zeAjAga8gmWkZEbXqAC8rNVqBQv72EKeJFGJyqnev6Xw==, tarball: https://npm.pkg.github.com/download/@hcengineering/tags/0.7.28/42545cc43e3c61e32ce96be87c245fa929f5cbb6}
'@hcengineering/theme@0.6.490':
resolution: {integrity: sha512-avJpw+/Sx0xZDctrFImANYBGK4TXdOphI158jcWgpC1ys6IBiRHKeeUtquTKud8q9mE06jb7TjOt9Oo/wrCGWg==, tarball: https://npm.pkg.github.com/download/@hcengineering/theme/0.6.490/2f8704cc5a089ce66e7d6dae4aa3ade6f3ea9b7a}
'@hcengineering/theme@0.7.28':
resolution: {integrity: sha512-Hv3wQKHr327TC7wMHCBiG4bMtg8vSlb7+UFwdKI8ztEs3359meqkXu+exqi7Nl5334voXIz5ZFxjwZwOMzNSFA==, tarball: https://npm.pkg.github.com/download/@hcengineering/theme/0.7.28/557640a7ba2ffaa4d6e5411a54577f60dc528300}
'@hcengineering/ui@0.6.490':
resolution: {integrity: sha512-W/7ljW5a/nSfzHQjJRknlnUkKC9vGRkXUjOiBKIjMiJxw7NZwNyfv5quQxNswdARMYRrAfcfdxUOhEmIZYC2Lg==, tarball: https://npm.pkg.github.com/download/@hcengineering/ui/0.6.490/27bb6fcefc46410988abe75404bbd0465d0dd2a1}
'@hcengineering/ui@0.7.28':
resolution: {integrity: sha512-QsUpO0+Idu1n2dC6KtorPJ6SSp30It8FHSuG3Y+Awv4td/ftVocOSFqhskbk+us9Yw9KqOYgkqhGYEHbfRfw4Q==, tarball: https://npm.pkg.github.com/download/@hcengineering/ui/0.7.28/82025fc775702ea8c8569611356662bfbf28eb74}
@@ -4543,7 +4564,7 @@ packages:
version: 0.0.0
'@rush-temp/model-recorder@file:projects/model-recorder.tgz':
resolution: {integrity: sha512-fRjS1msAMkf33rDm+7u/ywsOHfF0lu9VWivQjWiSnxBwakrn0Ztx3ZU/+XDNkj4CJMPd+lMPTdNx6gtuKUbZ6Q==, tarball: file:projects/model-recorder.tgz}
resolution: {integrity: sha512-zYwcCJVba+KhkMPQhgD7+ePk4S+zv9VyJ4h5PdIGgxfafKhugx5hcwVUthjVlgjyTs4merYQoTFl4ys30KatZA==, tarball: file:projects/model-recorder.tgz}
version: 0.0.0
'@rush-temp/model-recruit@file:projects/model-recruit.tgz':
@@ -4987,11 +5008,11 @@ packages:
version: 0.0.0
'@rush-temp/recorder-resources@file:projects/recorder-resources.tgz':
resolution: {integrity: sha512-Juvy2FmXIbOQdKI94A3Q/2UTQ0aLuMRiCMni/o1Xdwg5g14Pm5TC0ZjkHairKYm1LMDmmUWiRtxOiud+klTSmQ==, tarball: file:projects/recorder-resources.tgz}
resolution: {integrity: sha512-LiFazTORRWULjLfy0S9ycqsfJejlenpfjZ2EBswBl5BNBDSuyJPUHC6ykOn/j3GYgeY/gibIrDs/KFcQq7I4rA==, tarball: file:projects/recorder-resources.tgz}
version: 0.0.0
'@rush-temp/recorder@file:projects/recorder.tgz':
resolution: {integrity: sha512-vth2EZ1eZjV91sZVk3POK0QGcb6uoyMX7K2tiJcLWeIABOMfs4vaviuekXGqggE8awr51zxI8I7y+/SfYxEGoA==, tarball: file:projects/recorder.tgz}
resolution: {integrity: sha512-/Oi8ZzFzINaPy13dn0+UfkU+0VdCVzBOgXmArnYDqAq2ow503KqMf8Zv3/rW8oQOlYeS6Jdy6TlGp+IMX2CQuQ==, tarball: file:projects/recorder.tgz}
version: 0.0.0
'@rush-temp/recruit-assets@file:projects/recruit-assets.tgz':
@@ -5535,7 +5556,7 @@ packages:
version: 0.0.0
'@rush-temp/uploader-resources@file:projects/uploader-resources.tgz':
resolution: {integrity: sha512-gsRiOemRCAlhK0K5xzASMWgbIQpVAY8cYqKCms/N4rCDUDVtJL/jTKixLhjZDfUdSe+J537cRsHoP+xm19JrUQ==, tarball: file:projects/uploader-resources.tgz}
resolution: {integrity: sha512-FyPfNKvLhjTBhJwTqdKd0eq1snWLDYrHxxCWwoByt671DiuP+eRd3SKL7WnALLYBGZCxlkNTVvoDrFHPFeehYw==, tarball: file:projects/uploader-resources.tgz}
version: 0.0.0
'@rush-temp/uploader@file:projects/uploader.tgz':
@@ -14904,6 +14925,10 @@ snapshots:
'@hcengineering/core': 0.7.28
'@hcengineering/platform': 0.7.28
'@hcengineering/analytics@0.6.490':
dependencies:
'@hcengineering/platform': 0.6.490
'@hcengineering/analytics@0.7.28':
dependencies:
'@hcengineering/platform': 0.7.28
@@ -15030,16 +15055,39 @@ snapshots:
js-yaml: 4.1.0
typescript: 5.7.3
'@hcengineering/core@0.6.490':
dependencies:
'@hcengineering/analytics': 0.6.490
'@hcengineering/platform': 0.6.490
fast-equals: 5.2.2
'@hcengineering/core@0.7.28':
dependencies:
'@hcengineering/analytics': 0.7.28
'@hcengineering/platform': 0.7.28
fast-equals: 5.2.2
'@hcengineering/drive@0.6.490':
dependencies:
'@hcengineering/core': 0.6.490
'@hcengineering/platform': 0.6.490
'@hcengineering/preference': 0.6.490
'@hcengineering/ui': 0.6.490
'@hcengineering/platform@0.6.490':
dependencies:
intl-messageformat: 10.7.14
'@hcengineering/platform@0.7.28':
dependencies:
intl-messageformat: 10.7.14
'@hcengineering/preference@0.6.490':
dependencies:
'@hcengineering/core': 0.6.490
'@hcengineering/platform': 0.6.490
'@hcengineering/ui': 0.6.490
'@hcengineering/preference@0.7.28':
dependencies:
'@hcengineering/core': 0.7.28
@@ -15059,12 +15107,35 @@ snapshots:
'@hcengineering/ui': 0.7.28
'@hcengineering/view': 0.7.28
'@hcengineering/theme@0.6.490':
dependencies:
'@hcengineering/analytics': 0.6.490
'@hcengineering/platform': 0.6.490
svelte: 4.2.19
'@hcengineering/theme@0.7.28':
dependencies:
'@hcengineering/analytics': 0.7.28
'@hcengineering/platform': 0.7.28
svelte: 4.2.19
'@hcengineering/ui@0.6.490':
dependencies:
'@hcengineering/analytics': 0.6.490
'@hcengineering/core': 0.6.490
'@hcengineering/platform': 0.6.490
'@hcengineering/theme': 0.6.490
autolinker: 4.0.0
date-fns: 2.30.0
date-fns-tz: 2.0.0(date-fns@2.30.0)
dompurify: 3.2.3
emoji-regex: 10.3.0
emojibase: 16.0.0
fast-equals: 5.2.2
hls.js: 1.5.20
plyr: 3.7.8
svelte: 4.2.19
'@hcengineering/ui@0.7.28':
dependencies:
'@hcengineering/analytics': 0.7.28
@@ -23492,7 +23563,6 @@ snapshots:
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.19)
sass: 1.71.1
stream-browserify: 3.0.0
svelte: 4.2.19
svelte-check: 3.6.9(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(postcss@8.5.3)(sass@1.71.1)(svelte@4.2.19)
svelte-eslint-parser: 0.33.1(svelte@4.2.19)
@@ -23520,6 +23590,7 @@ snapshots:
'@rush-temp/recorder@file:projects/recorder.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies:
'@hcengineering/drive': 0.6.490
'@types/jest': 29.5.12
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3)
@@ -27416,7 +27487,6 @@ snapshots:
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.19)
sass: 1.71.1
stream-browserify: 3.0.0
svelte: 4.2.19
svelte-check: 3.6.9(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(postcss@8.5.3)(sass@1.71.1)(svelte@4.2.19)
svelte-eslint-parser: 0.33.1(svelte@4.2.19)
+3 -1
View File
@@ -59,6 +59,7 @@ import { aiBotId, aiBotOperation } from '@hcengineering/model-ai-bot'
import { chatId, chatOperation } from '@hcengineering/model-chat'
import { inboxId, inboxOperation } from '@hcengineering/model-inbox'
import { processId, processOperation } from '@hcengineering/model-process'
import { recorderId, recorderOperation } from '@hcengineering/model-recorder'
export const migrateOperations: [string, MigrateOperation][] = [
['core', coreOperation],
@@ -106,5 +107,6 @@ export const migrateOperations: [string, MigrateOperation][] = [
[aiBotId, aiBotOperation],
[chatId, chatOperation],
[inboxId, inboxOperation],
[processId, processOperation]
[processId, processOperation],
[recorderId, recorderOperation]
]
+1 -1
View File
@@ -15,7 +15,7 @@
import { type MigrateOperation, type MigrationClient, type MigrationUpgradeClient } from '@hcengineering/model'
export const loveOperation: MigrateOperation = {
export const exportOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {},
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>): Promise<void> {}
}
+1 -1
View File
@@ -15,7 +15,7 @@
import { type MigrateOperation, type MigrationClient, type MigrationUpgradeClient } from '@hcengineering/model'
export const loveOperation: MigrateOperation = {
export const printOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {},
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>): Promise<void> {}
}
+6 -1
View File
@@ -30,9 +30,14 @@
"dependencies": {
"@hcengineering/model": "^0.6.11",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/core": "^0.6.32",
"@hcengineering/drive": "^0.6.0",
"@hcengineering/uploader": "^0.6.0",
"@hcengineering/recorder": "^0.6.0",
"@hcengineering/recorder-resources": "^0.6.0"
"@hcengineering/recorder-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/ui": "^0.6.15"
}
}
+9 -2
View File
@@ -15,13 +15,15 @@
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import presentation from '@hcengineering/model-presentation'
import uploader from '@hcengineering/uploader'
import workbench from '@hcengineering/workbench'
import recorder from './plugin'
import uploader from '@hcengineering/uploader'
export { recorderId } from '@hcengineering/recorder'
export { recorder as default }
export * from './migration'
export function createModel (builder: Builder): void {
builder.createDoc(uploader.class.UploadHandlerDefinition, core.space.Model, {
@@ -29,4 +31,9 @@ export function createModel (builder: Builder): void {
label: recorder.string.Record,
icon: recorder.icon.Record
})
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: workbench.extensions.WorkbenchExtensions,
component: recorder.component.WorkbenchExtension
})
}
+49
View File
@@ -0,0 +1,49 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import drive from '@hcengineering/drive'
import {
type MigrateOperation,
type MigrationClient,
type MigrationUpgradeClient,
createDefaultSpace,
tryUpgrade
} from '@hcengineering/model'
import { recorderId } from '@hcengineering/recorder'
import recorder from './plugin'
export const recorderOperation: MigrateOperation = {
async migrate (client: MigrationClient, mode): Promise<void> {},
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>, mode): Promise<void> {
await tryUpgrade(mode, state, client, recorderId, [
{
state: 'create-drive',
func: async (client) => {
await createDefaultSpace(
client,
recorder.space.Drive,
{
name: 'Screen Recordings',
description: 'Screen recordings',
type: drive.spaceType.DefaultDrive,
autoJoin: true
},
drive.class.Drive
)
}
}
])
}
}
+8 -1
View File
@@ -13,8 +13,15 @@
// limitations under the License.
//
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import core from '@hcengineering/core'
import { mergeIds } from '@hcengineering/platform'
import { recorderId } from '@hcengineering/recorder'
import recorder from '@hcengineering/recorder-resources/src/plugin'
import { type AnyComponent } from '@hcengineering/ui/src/types'
export default mergeIds(recorderId, recorder, {})
export default mergeIds(recorderId, recorder, {
component: {
WorkbenchExtension: '' as AnyComponent
}
})
+22
View File
@@ -0,0 +1,22 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { themeStore } from '.'
</script>
<div class:theme-dark={!$themeStore.dark} class:theme-light={$themeStore.dark}>
<slot />
</div>
+1
View File
@@ -18,6 +18,7 @@ import '@hcengineering/platform-rig/profiles/ui/svelte'
import { derived, writable } from 'svelte/store'
export { default as Theme } from './Theme.svelte'
export { default as InvertedTheme } from './InvertedTheme.svelte'
/**
* @public
-4
View File
@@ -361,8 +361,6 @@
--theme-link-preview-bg-color: #2B2D41;
--theme-link-preview-description-color: #C1C9D6;
--theme-link-preview-text-color:#FFFFFF;
--theme-recorder-panel-bg: #2A2939;
--theme-recorder-active-button: #A5BDFF0D;
--theme-dialog-border-color: rgba(255, 255, 255, 0.1);
--theme-dialog-background-color: #2a2938;
@@ -475,8 +473,6 @@
--theme-list-divider-color: rgba(0, 0, 0, .07);
--theme-list-subheader-divider: rgba(0, 0, 0, .06);
--theme-recorder-panel-bg: #FFFFFF;
--theme-list-button-color: #F2F2F4;
--theme-list-button-hover: #E8E8EA;
--theme-link-button-color: #E5E5E7;
+8 -1
View File
@@ -3,7 +3,14 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99967 0.833374C6.61896 0.833374 5.49967 1.95266 5.49967 3.33337V7.33337C5.49967 8.71409 6.61896 9.83337 7.99967 9.83337C9.38039 9.83337 10.4997 8.71409 10.4997 7.33337V3.33337C10.4997 1.95266 9.38039 0.833374 7.99967 0.833374ZM6.49967 3.33337C6.49967 2.50495 7.17125 1.83337 7.99967 1.83337C8.8281 1.83337 9.49967 2.50495 9.49967 3.33337V7.33337C9.49967 8.1618 8.8281 8.83337 7.99967 8.83337C7.17125 8.83337 6.49967 8.1618 6.49967 7.33337V3.33337Z" fill="currentColor"/>
<path d="M3.33301 6.16671C3.60915 6.16671 3.83301 6.39057 3.83301 6.66671V7.33337C3.83301 9.63457 5.69849 11.5 7.99967 11.5C10.3009 11.5 12.1663 9.63457 12.1663 7.33337V6.66671C12.1663 6.39057 12.3902 6.16671 12.6663 6.16671C12.9425 6.16671 13.1663 6.39057 13.1663 6.66671V7.33337C13.1663 10.0182 11.1185 12.2247 8.49967 12.4762V14.1667H9.99967C10.2758 14.1667 10.4997 14.3906 10.4997 14.6667C10.4997 14.9429 10.2758 15.1667 9.99967 15.1667H5.99967C5.72353 15.1667 5.49967 14.9429 5.49967 14.6667C5.49967 14.3906 5.72353 14.1667 5.99967 14.1667H7.49967V12.4762C4.88081 12.2247 2.83301 10.0182 2.83301 7.33337V6.66671C2.83301 6.39057 3.05687 6.16671 3.33301 6.16671Z" fill="currentColor"/>
</symbol>
<symbol id="micoff" viewBox="0 0 16 16" fill="none">
<path d="M8 0.833313C6.61925 0.833313 5.5 1.95261 5.5 3.33331V3.66665C5.5 3.94279 5.72386 4.16665 6 4.16665C6.27614 4.16665 6.5 3.94279 6.5 3.66665V3.33331C6.5 2.50488 7.17155 1.83331 8 1.83331C8.82845 1.83331 9.5 2.50488 9.5 3.33331V6.99998C9.5 7.27612 9.72386 7.49998 10 7.49998C10.2761 7.49998 10.5 7.27612 10.5 6.99998V3.33331C10.5 1.95261 9.38075 0.833313 8 0.833313Z" fill="currentColor"/>
<path d="M5.50581 6.2129L1.64645 2.35353C1.45118 2.15827 1.45118 1.84169 1.64645 1.64643C1.84171 1.45116 2.15829 1.45116 2.35355 1.64643L10.9664 10.2593C11.7086 9.50694 12.1667 8.47362 12.1667 7.33331V6.66665C12.1667 6.3905 12.3905 6.16665 12.6667 6.16665C12.9428 6.16665 13.1667 6.3905 13.1667 6.66665V7.33331C13.1667 8.74977 12.5967 10.0331 11.6736 10.9664L14.3536 13.6464C14.5488 13.8417 14.5488 14.1583 14.3536 14.3535C14.1583 14.5488 13.8417 14.5488 13.6464 14.3535L10.9017 11.6088C10.2043 12.0831 9.38472 12.3911 8.5 12.4761V14.1666H10C10.2761 14.1666 10.5 14.3905 10.5 14.6666C10.5 14.9428 10.2761 15.1666 10 15.1666H6C5.72386 15.1666 5.5 14.9428 5.5 14.6666C5.5 14.3905 5.72386 14.1666 6 14.1666H7.5V12.4761C4.88113 12.2246 2.83333 10.0181 2.83333 7.33331V6.66665C2.83333 6.3905 3.05719 6.16665 3.33333 6.16665C3.60948 6.16665 3.83333 6.3905 3.83333 6.66665V7.33331C3.83333 9.6345 5.69882 11.5 8 11.5C8.79861 11.5 9.54474 11.2753 10.1786 10.8857L9.12041 9.8275C7.17286 9.72087 5.61244 8.16046 5.50581 6.2129Z" fill="currentColor" />
</symbol>
<symbol id="cam" viewBox="0 0 16 16" fill="none">
<path d="M10.1667 4.66665C10.1667 4.20641 9.79361 3.83331 9.33337 3.83331H2.66671C2.20647 3.83331 1.83337 4.20641 1.83337 4.66665V11.3333C1.83337 11.7936 2.20647 12.1666 2.66671 12.1666H9.33337C9.79361 12.1666 10.1667 11.7936 10.1667 11.3333V4.66665ZM11.1667 8.04034L11.1706 8.0742C11.1777 8.10798 11.1949 8.13902 11.2207 8.16274L14.1667 10.8626V5.1367L11.2207 7.83722C11.1864 7.86872 11.1668 7.91305 11.1667 7.95962V8.04034ZM11.1667 6.52928L13.7702 4.14321L13.8737 4.06183C14.4089 3.70208 15.1667 4.07789 15.1667 4.75779V11.2422C15.1667 11.9672 14.3047 12.3466 13.7702 11.8568L11.1667 9.47003V11.3333C11.1667 12.3458 10.3459 13.1666 9.33337 13.1666H2.66671C1.65419 13.1666 0.833374 12.3458 0.833374 11.3333V4.66665C0.833374 3.65412 1.65419 2.83331 2.66671 2.83331H9.33337C10.3459 2.83331 11.1667 3.65412 11.1667 4.66665V6.52928Z" fill="#5D6067"/>
<path d="M10.1667 4.66665C10.1667 4.20641 9.79361 3.83331 9.33337 3.83331H2.66671C2.20647 3.83331 1.83337 4.20641 1.83337 4.66665V11.3333C1.83337 11.7936 2.20647 12.1666 2.66671 12.1666H9.33337C9.79361 12.1666 10.1667 11.7936 10.1667 11.3333V4.66665ZM11.1667 8.04034L11.1706 8.0742C11.1777 8.10798 11.1949 8.13902 11.2207 8.16274L14.1667 10.8626V5.1367L11.2207 7.83722C11.1864 7.86872 11.1668 7.91305 11.1667 7.95962V8.04034ZM11.1667 6.52928L13.7702 4.14321L13.8737 4.06183C14.4089 3.70208 15.1667 4.07789 15.1667 4.75779V11.2422C15.1667 11.9672 14.3047 12.3466 13.7702 11.8568L11.1667 9.47003V11.3333C11.1667 12.3458 10.3459 13.1666 9.33337 13.1666H2.66671C1.65419 13.1666 0.833374 12.3458 0.833374 11.3333V4.66665C0.833374 3.65412 1.65419 2.83331 2.66671 2.83331H9.33337C10.3459 2.83331 11.1667 3.65412 11.1667 4.66665V6.52928Z" fill="currentColor"/>
</symbol>
<symbol id="camoff" viewBox="0 0 16 16" fill="none">
<path d="M0.979814 0.979814C1.16288 0.796745 1.4527 0.785103 1.64909 0.945309L1.68685 0.979814L15.0202 14.3131L15.0547 14.3509C15.2149 14.5473 15.2032 14.8371 15.0202 15.0202C14.8371 15.2032 14.5473 15.2149 14.3509 15.0547L14.3131 15.0202L11.1016 11.8086C10.892 12.5903 10.1812 13.1667 9.33333 13.1667H2.66666C1.65414 13.1667 0.83333 12.3459 0.83333 11.3333V4.66666C0.83333 3.81893 1.40922 3.10753 2.19075 2.89778L0.979814 1.68685L0.945309 1.64909C0.785103 1.4527 0.796745 1.16288 0.979814 0.979814ZM14.1667 10.6667V5.13671L11.5631 7.52343C11.0286 8.01328 10.1667 7.6339 10.1667 6.90885V4.66666C10.1667 4.20643 9.79357 3.83333 9.33333 3.83333H8.33333C8.05719 3.83333 7.83333 3.60947 7.83333 3.33333C7.83333 3.05719 8.05719 2.83333 8.33333 2.83333H9.33333C10.3459 2.83333 11.1667 3.65414 11.1667 4.66666V6.52929L13.7702 4.14323L13.8737 4.06185C14.4088 3.7021 15.1667 4.07791 15.1667 4.75781V10.6667C15.1667 10.9428 14.9428 11.1667 14.6667 11.1667C14.3905 11.1667 14.1667 10.9428 14.1667 10.6667ZM1.83333 11.3333C1.83333 11.7936 2.20643 12.1667 2.66666 12.1667H9.33333C9.79357 12.1667 10.1667 11.7936 10.1667 11.3333V10.8737L3.1263 3.83333H2.66666C2.20643 3.83333 1.83333 4.20643 1.83333 4.66666V11.3333Z" fill="currentColor"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

+3 -1
View File
@@ -20,5 +20,7 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(media.icon, {
Mic: `${icons}#mic`,
Cam: `${icons}#camera`
Cam: `${icons}#cam`,
MicOff: `${icons}#micoff`,
CamOff: `${icons}#camoff`
})
@@ -35,7 +35,6 @@
await releaseStream(stream)
try {
console.log('getUserMedia', constraints)
const newStream = await navigator.mediaDevices.getUserMedia(constraints)
if (selected.deviceId === device.deviceId) {
stream = newStream
-2
View File
@@ -33,8 +33,6 @@ export default mergeIds(mediaId, media, {
DefaultSpeaker: '' as IntlString,
TurnOnCam: '' as IntlString,
TurnOffCam: '' as IntlString,
TurnOnMic: '' as IntlString,
TurnOffMic: '' as IntlString,
On: '' as IntlString,
Off: '' as IntlString
}
+2 -2
View File
@@ -99,12 +99,12 @@ class MediaSessionImpl
registerSession(this)
}
setCamera (state: CamState): void {
setCamera (state: CamState | undefined): void {
this.state.camera = state
this.emit('update', this.state)
}
setMicrophone (state: MicState): void {
setMicrophone (state: MicState | undefined): void {
this.state.microphone = state
this.emit('update', this.state)
}
+8 -2
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import type { Plugin, Asset } from '@hcengineering/platform'
import type { Plugin, Asset, IntlString } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { type ComponentExtensionId } from '@hcengineering/ui'
@@ -27,7 +27,13 @@ export const mediaPlugin = plugin(mediaId, {
},
icon: {
Mic: '' as Asset,
Cam: '' as Asset
Cam: '' as Asset,
MicOff: '' as Asset,
CamOff: '' as Asset
},
string: {
TurnOnMic: '' as IntlString,
TurnOffMic: '' as IntlString
}
})
+2 -2
View File
@@ -35,12 +35,12 @@ export interface MediaSession extends TypedEmitter<MediaSessionEvents> {
/**
* Enable or disable camera
*/
setCamera: (state: CamState) => void
setCamera: (state: CamState | undefined) => void
/**
* Enable or disable microphone
*/
setMicrophone: (state: MicState) => void
setMicrophone: (state: MicState | undefined) => void
/**
* Enable or disable media feature
+4 -6
View File
@@ -140,7 +140,7 @@ export function cleanupDeviceLabel (label: string): string {
}
export async function getCameraStream (
deviceId: MediaDeviceInfo['deviceId'],
deviceId: MediaDeviceInfo['deviceId'] | undefined,
constraints?: MediaStreamConstraints
): Promise<MediaStream | null> {
if (navigator?.mediaDevices === undefined) {
@@ -151,17 +151,16 @@ export async function getCameraStream (
let video: MediaTrackConstraints | boolean
if (constraints?.video === undefined) {
video = { deviceId }
video = deviceId !== undefined ? { deviceId: { exact: deviceId } } : true
} else if (constraints.video === true) {
video = { deviceId }
video = deviceId !== undefined ? { deviceId: { exact: deviceId } } : true
} else if (constraints.video === false) {
video = false
} else {
video = { deviceId, ...constraints.video }
video = { deviceId: { exact: deviceId }, ...constraints.video }
}
try {
console.info('Using media device', constraints)
return await navigator.mediaDevices.getUserMedia({ ...constraints, video })
} catch (err: any) {
console.warn('Failed to get camera stream', err)
@@ -191,7 +190,6 @@ export async function getMicrophoneStream (
}
try {
console.info('Using media device', constraints)
return await navigator.mediaDevices.getUserMedia({ ...constraints, audio })
} catch (err: any) {
console.warn('Failed to get microphone stream', err)
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Zastavit",
"Resume": "Pokračovat",
"Record": "Nahrávat",
"CancelRecording": "Zrušit nahrávání",
"CancelRecordingConfirm": "Opravdu chcete zrušit nahrávání?",
"RestartRecording": "Restart nahrávání",
"RestartRecordingConfirm": "Opravdu chcete restartovat nahrávání?",
"RecordingTitle": "Nahrávání obrazovky {date}",
"ClickToSkip": "Kliknutím přeskočíte"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Stopp",
"Resume": "Fortsetzen",
"Record": "Aufnehmen",
"CancelRecording": "Aufnahme abbrechen",
"CancelRecordingConfirm": "Möchten Sie die Aufnahme abbrechen?",
"RestartRecording": "Aufnahme neu starten",
"RestartRecordingConfirm": "Möchten Sie die Aufnahme neu starten?",
"RecordingTitle": "Bildschirmaufnahme {date}",
"ClickToSkip": "Zum Überspringen klicken"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Stop",
"Resume": "Resume",
"Record": "Record",
"CancelRecording": "Cancel recording",
"CancelRecordingConfirm": "Are you sure you want to cancel recording?",
"RestartRecording": "Restart recording",
"RestartRecordingConfirm": "Are you sure you want to restart recording?",
"RecordingTitle": "Screen Recording {date}",
"ClickToSkip": "Click to skip"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Detener",
"Resume": "Reanudar",
"Record": "Grabar",
"CancelRecording": "Cancelar grabación",
"CancelRecordingConfirm": "¿Está seguro de que desea cancelar la grabación?",
"RestartRecording": "Reiniciar grabación",
"RestartRecordingConfirm": "¿Está seguro de que desea reiniciar la grabación?",
"RecordingTitle": "Grabación de pantalla {date}",
"ClickToSkip": "Haz clic para saltar"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Arrêter",
"Resume": "Reprendre",
"Record": "Enregistrer",
"CancelRecording": "Annuler l'enregistrement",
"CancelRecordingConfirm": "Êtes-vous sûr de vouloir annuler l'enregistrement?",
"RestartRecording": "Redémarrer l'enregistrement",
"RestartRecordingConfirm": "Êtes-vous sûr de vouloir redémarrer l'enregistrement?",
"RecordingTitle": "Enregistrement d'écran {date}",
"ClickToSkip": "Cliquer pour ignorer"
}
}
+10 -5
View File
@@ -1,10 +1,15 @@
{
"string": {
"Cancel": "Cancel",
"Pause": "Pause",
"Cancel": "Annulla",
"Pause": "Pausa",
"Stop": "Stop",
"Resume": "Resume",
"Record": "Record",
"ClickToSkip": "Click to skip"
"Resume": "Riprendi",
"Record": "Registra",
"CancelRecording": "Annulla registrazione",
"CancelRecordingConfirm": "Sei sicuro di voler annullare la registrazione?",
"RestartRecording": "Riavvia registrazione",
"RestartRecordingConfirm": "Sei sicuro di voler riavviare la registrazione?",
"RecordingTitle": "Registrazione dello schermo {date}",
"ClickToSkip": "Fai clic per saltare"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "停止",
"Resume": "再開",
"Record": "記録",
"CancelRecording": "録音をキャンセル",
"CancelRecordingConfirm": "録音をキャンセルしてもよろしいですか?",
"RestartRecording": "録音を再開",
"RestartRecordingConfirm": "録音を再開してもよろしいですか?",
"RecordingTitle": "画面録画 {date}",
"ClickToSkip": "クリックしてスキップ"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Parar",
"Resume": "Retomar",
"Record": "Gravar",
"CancelRecording": "Cancelar gravação",
"CancelRecordingConfirm": "Tem certeza de que deseja cancelar a gravação?",
"RestartRecording": "Reiniciar gravação",
"RestartRecordingConfirm": "Tem certeza de que deseja reiniciar a gravação?",
"RecordingTitle": "Gravação de tela {date}",
"ClickToSkip": "Clique para pular"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "Стоп",
"Resume": "Продолжить",
"Record": "Запись",
"CancelRecording": "Отменить запись",
"CancelRecordingConfirm": "Вы уверены, что хотите отменить запись?",
"RestartRecording": "Перезапустить запись",
"RestartRecordingConfirm": "Вы уверены, что хотите перезапустить запись?",
"RecordingTitle": "Запись экрана {date}",
"ClickToSkip": "Нажмите для пропуска"
}
}
+5
View File
@@ -5,6 +5,11 @@
"Stop": "停止",
"Resume": "继续",
"Record": "录制",
"CancelRecording": "取消录制",
"CancelRecordingConfirm": "您确定要取消录制吗?",
"RestartRecording": "重新开始录制",
"RestartRecordingConfirm": "您确定要重新开始录制吗?",
"RecordingTitle": "屏幕录制 {date}",
"ClickToSkip": "点击跳过"
}
}
+6 -1
View File
@@ -40,16 +40,21 @@
},
"dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/presentation": "^0.6.3",
"@hcengineering/recorder": "^0.6.0",
"@hcengineering/theme": "^0.6.5",
"@hcengineering/media": "^0.6.0",
"@hcengineering/media-resources": "^0.6.0",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/uploader": "^0.6.0",
"@hcengineering/drive": "^0.6.0",
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/analytics": "^0.6.0",
"install": "^0.13.0",
"stream-browserify": "^3.0.0",
"svelte": "^4.2.19",
"tus-js-client": "^4.3.1"
}
@@ -14,125 +14,159 @@
-->
<script lang="ts">
import { onDestroy } from 'svelte'
import Camera from './icons/Camera.svelte'
import Close from './icons/Close.svelte'
import { getCurrentEmployee } from '@hcengineering/contact'
import { Button, IconClose } from '@hcengineering/ui'
import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
import { createEventDispatcher } from 'svelte'
let showButtons = false
let showCamera = false
import IconCircleLarge from './icons/CircleLarge.svelte'
import IconCircleMedium from './icons/CircleMedium.svelte'
import IconCircleSmall from './icons/CircleSmall.svelte'
let videoElement: HTMLVideoElement
let stream: MediaStream | null = null
export let stream: MediaStream | null
export let isCamEnabled: boolean = true
export let size: 'small' | 'medium' | 'large' = 'medium'
const stopCamera = (): void => {
if (stream === null) {
return
}
stream.getTracks().forEach((track) => {
track.stop()
})
videoElement.srcObject = null
stream = null
showCamera = false
const me = getCurrentEmployee()
const meName = $personByIdStore.get(me)?.name
const meAvatar = $personByIdStore.get(me)
const dispatch = createEventDispatcher()
let video: HTMLVideoElement
$: if (video != null && video.srcObject !== stream) {
video.srcObject = stream
}
const startCamera = async (): Promise<void> => {
stopCamera()
showCamera = true
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { frameRate: { ideal: 30 } } })
videoElement.srcObject = stream
} catch (err) {
console.log(err)
}
function handleCloseCamera (): void {
dispatch('close')
}
onDestroy(async (): Promise<void> => {
stopCamera()
})
function handleCameraSizeSmall (): void {
size = 'small'
}
function handleCameraSizeMedium (): void {
size = 'medium'
}
function handleCameraSizeLarge (): void {
size = 'large'
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="movable"
on:mouseenter={() => {
showButtons = true
}}
on:mouseleave={() => {
showButtons = false
}}
>
{#if showCamera}
<video class="video video-container" bind:this={videoElement} autoplay muted playsinline> </video>
{#if showButtons}
<div
class="camera-control"
on:click={() => {
stopCamera()
}}
>
<Close size="full" />
</div>
{/if}
<div class="container {size}">
{#if stream != null && isCamEnabled}
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} autoplay muted playsinline />
{:else}
<div class="control">
<span
class="control-button"
on:click={() => {
startCamera()
}}
>
<Camera size="medium" />
</span>
</div>
<Avatar variant={'circle'} size={'full'} name={meName} person={meAvatar} showStatus={false} adaptiveName />
{/if}
<div class="controls">
<div class="buttons-panel">
<Button icon={IconClose} kind={'icon'} size={'small'} noFocus on:click={handleCloseCamera} />
<div class="divider" />
<Button
icon={IconCircleSmall}
kind={'icon'}
size={'small'}
selected={size === 'small'}
noFocus
on:click={handleCameraSizeSmall}
/>
<Button
icon={IconCircleMedium}
kind={'icon'}
size={'small'}
selected={size === 'medium'}
noFocus
on:click={handleCameraSizeMedium}
/>
<Button
icon={IconCircleLarge}
kind={'icon'}
size={'small'}
selected={size === 'large'}
noFocus
on:click={handleCameraSizeLarge}
/>
</div>
</div>
</div>
<style lang="scss">
.video-container {
width: 350px;
height: 350px;
transform: rotateY(180deg);
.container {
position: relative;
transition: all 0.2s ease;
video {
border-radius: 50%;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-bg-color);
transform: rotateY(180deg);
}
&.small {
width: 12rem;
height: 12rem;
}
&.medium {
width: 18rem;
height: 18rem;
}
&.large {
width: 27rem;
height: 27rem;
}
.controls {
visibility: hidden;
}
&:hover {
.controls {
visibility: visible;
}
}
}
.video {
border-radius: 50%;
object-fit: cover;
}
.control {
min-height: 2.8rem;
min-width: 2.8rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
background: var(--theme-recorder-panel-bg);
padding-left: 0.25rem;
border-radius: 0.5rem;
transition:
max-width 0.4s ease-in-out,
padding 0.3s ease-in-out;
overflow: hidden;
border: 0.5px solid var(--button-border-color);
}
.camera-control {
.controls {
position: absolute;
transform: translateY(-350px);
padding: 0.5rem 0.5rem;
border-radius: 0.5rem;
justify-content: center;
cursor: pointer;
width: 100%;
height: 25%;
bottom: 0;
z-index: auto;
display: flex;
align-items: center;
flex-direction: col;
align-items: start;
justify-content: center;
}
.control-button {
padding: 0.5rem 0.5rem;
border-radius: 0.5rem;
justify-content: center;
cursor: pointer;
.buttons-panel {
display: flex;
flex-direction: row;
align-items: center;
background: var(--theme-recorder-active-button);
border-radius: 0.5rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.125rem;
padding: 0.125rem;
}
.divider {
width: 1px;
background-color: var(--theme-divider-color);
align-self: stretch;
}
</style>
@@ -0,0 +1,153 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { resizeObserver } from '@hcengineering/ui'
import { onMount } from 'svelte'
export let key: string | undefined = undefined
// expected to be bound outside
export let dragging = false
export let posX: number = 0
export let posY: number = 0
// distances to the edges of the screen
let right: number = document.body.clientWidth
let top: number = document.body.clientHeight
let bottom: number = 0
let left: number = 0
// drag start position
let dragStartX = 0
let dragStartY = 0
// drag offset
let dragOffsetX = 0
let dragOffsetY = 0
// window and client sizes
let clientWidth = 0
let clientHeight = 0
let windowClientWidth = document.body.clientWidth
let windowClientHeight = document.body.clientHeight
// On mount, restore saved position
onMount(() => {
if (key === undefined) return
const saved = localStorage.getItem(key)
if (saved != null) {
try {
const { l, r, t, b } = JSON.parse(saved)
if (typeof l === 'number' && typeof r === 'number' && typeof t === 'number' && typeof b === 'number') {
left = l
right = r
top = t
bottom = b
}
} catch {}
}
})
onMount(() => {
const observer = new ResizeObserver(() => {
windowClientWidth = document.body.clientWidth
windowClientHeight = document.body.clientHeight
})
observer.observe(document.body)
return () => {
observer.disconnect()
}
})
function handleDragStart (event: MouseEvent): void {
dragStartX = event.clientX
dragStartY = event.clientY
dragOffsetX = event.clientX - posX
dragOffsetY = event.clientY - posY
window.addEventListener('mousemove', handleDrag)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragEnd (): void {
window.removeEventListener('mousemove', handleDrag)
window.removeEventListener('mouseup', handleDragEnd)
if (dragging && key !== undefined) {
try {
localStorage.setItem(key, JSON.stringify({ l: left, r: right, t: top, b: bottom }))
} catch {}
}
dragging = false
}
function handleDrag (event: MouseEvent): void {
if (!dragging) {
if (Math.abs(event.clientX - dragStartX) < 5 && Math.abs(event.clientY - dragStartY) < 5) {
return
}
dragging = true
}
const x = event.clientX - dragOffsetX
const y = event.clientY - dragOffsetY
// When dragging, update distances to the edges of the screen
top = Math.max(windowClientHeight / 2 + y, 0)
bottom = Math.max(windowClientHeight / 2 - y - clientHeight, 0)
left = Math.max(windowClientWidth / 2 + x, 0)
right = Math.max(windowClientWidth / 2 - x - clientWidth, 0)
}
// Calculate the popup position based on the distances to the edges of the screen
$: posX = left < right ? left - windowClientWidth / 2 : windowClientWidth / 2 - clientWidth - right
$: posY = top < bottom ? top - windowClientHeight / 2 : windowClientHeight / 2 - clientHeight - bottom
// Position the element based on the distances to the edges of the screen
// If it is closer to the left edge, position it to the left, otherwise position it to the right
// If it is closer to the top edge, position it to the top, otherwise position it to the bottom
$: styleX = left < right ? `left: ${left - windowClientWidth / 2}px;` : `right: ${right - windowClientWidth / 2}px;`
$: styleY = top < bottom ? `top: ${top - windowClientHeight / 2}px;` : `bottom: ${bottom - windowClientHeight / 2}px;`
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="draggable"
class:dragging
style="{styleX} {styleY}"
on:mousedown={handleDragStart}
on:mouseup={handleDragEnd}
on:click={handleDragEnd}
use:resizeObserver={(element) => {
clientWidth = element.clientWidth
clientHeight = element.clientHeight
}}
>
<slot />
</div>
<style lang="scss">
.draggable {
position: absolute;
cursor: grab;
user-select: none;
&.dragging {
cursor: grabbing;
}
}
</style>
@@ -14,55 +14,43 @@
-->
<script lang="ts">
import Record from './icons/Record.svelte'
import Play from './icons/Play.svelte'
import Stop from './icons/Stop.svelte'
import Trash from './icons/Trash.svelte'
import Pause from './icons/Pause.svelte'
import Expand from './icons/Expand.svelte'
import Collapse from './icons/Collapse.svelte'
import { ScreenRecorder } from '../screen-recorder'
import { createEventDispatcher } from 'svelte'
import { showPopup, Label } from '@hcengineering/ui'
import Countdown from './Countdown.svelte'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Blob, type Ref } from '@hcengineering/core'
import media from '@hcengineering/media'
import { MessageBox } from '@hcengineering/presentation'
import { Button, IconClose, IconDelete, closeTooltip, showPopup } from '@hcengineering/ui'
import { type FileUploadCallback } from '@hcengineering/uploader'
import { createEventDispatcher, onMount } from 'svelte'
import plugin from '../plugin'
import {
cancelRecording,
pauseRecording,
restartRecording,
resumeRecording,
startRecording,
stopRecording
} from '../recording'
import { recording } from '../stores'
import { formatElapsedTime, formatRecordingName } from '../utils'
let timer: number | undefined = undefined
let recorder: ScreenRecorder | null = null
let seconds = 0
let recordingId: string | null = null
import Countdown from './Countdown.svelte'
import IconRestart from './icons/Restart.svelte'
import IconPause from './icons/Pause.svelte'
import IconPlay from './icons/Play.svelte'
import IconStop from './icons/Stop.svelte'
import IconRecord from './icons/Record.svelte'
const distpacher = createEventDispatcher()
export let cameraStream: MediaStream | null = null
export let dragging = false
export let direction: 'top' | 'bottom' = 'bottom'
export let onFileUploaded: FileUploadCallback | undefined
enum RecordingState {
Recording = 'recording',
Paused = 'paused',
Inactive = 'inactive'
}
// expected to be bound outside
export let isMicEnabled = true
let state = RecordingState.Inactive
let expanded = true
let time = '0:00'
const dispatch = createEventDispatcher()
function formatTime (s: number): string {
const mins = Math.floor(s / 60)
const secs = s % 60
return `${mins}:${secs < 10 ? '0' : ''}${secs}`
}
async function createScreenRecorder (): Promise<void> {
recorder = await ScreenRecorder.fromNavigatorMediaDevices({
endpoint: getMetadata(plugin.metadata.StreamUrl) ?? '',
token: getMetadata(presentation.metadata.Token) ?? '',
workspace: getMetadata(presentation.metadata.WorkspaceUuid) ?? '',
onFinish: async (x) => {
recordingId = x + '_master.m3u8'
},
fps: 30
})
}
$: state = $recording
async function showCountdown (): Promise<void> {
const showPopupPromise = new Promise<void>((resolve) => {
@@ -84,170 +72,221 @@
await showPopupPromise
}
async function startRecording (): Promise<void> {
if (state === RecordingState.Recording) {
async function handleStartRecording (): Promise<void> {
if (state != null) {
console.warn('Recording already in progress', state)
return
}
if (state === RecordingState.Inactive) {
try {
await createScreenRecorder()
} catch (err) {
console.log('cant create screen recorder', err)
distpacher('close', true)
return
await startRecording({
cameraStream,
microphoneStream: null,
fps: 30,
onSuccess: async (blobId: string) => {
if (onFileUploaded !== undefined) {
const name = await formatRecordingName(new Date())
const blob = new Blob([], { type: 'video/x-mpegURL' })
await onFileUploaded({
uuid: blobId as Ref<Blob>,
name,
type: blob.type,
file: blob,
path: undefined,
metadata: undefined,
navigateOnUpload: true
})
}
},
onError: (error: any) => {
console.error('Recording upload failed', error)
}
})
}
async function handleResumeRecording (): Promise<void> {
await resumeRecording()
}
async function handlePauseRecording (): Promise<void> {
await pauseRecording()
}
async function handleStopRecording (): Promise<void> {
await stopRecording()
dispatch('close')
}
async function handleRestartRecording (): Promise<void> {
await pauseRecording()
showPopup(
MessageBox,
{
label: plugin.string.RestartRecording,
message: plugin.string.RestartRecordingConfirm
},
undefined,
async (restart: boolean) => {
if (restart) {
await restartRecording()
} else {
await resumeRecording()
}
}
)
}
async function handleCancelRecording (): Promise<void> {
await cancelRecording()
dispatch('close', true)
}
async function handleDeleteRecording (): Promise<void> {
await pauseRecording()
showPopup(
MessageBox,
{
label: plugin.string.CancelRecording,
message: plugin.string.CancelRecordingConfirm
},
undefined,
async (restart: boolean) => {
if (restart) {
await cancelRecording()
dispatch('close', true)
} else {
await resumeRecording()
}
}
)
}
function handleToggleMic (): void {
isMicEnabled = !isMicEnabled
}
$: if (dragging) {
closeTooltip()
}
let elapsedTime = 0
onMount(() => {
const timer = setInterval(() => {
if ($recording !== null) {
elapsedTime = $recording.recorder.elapsedTime
}
await showCountdown()
recorder?.start()
} else {
recorder?.resume()
}
timer = setInterval(() => {
seconds++
time = formatTime(seconds)
}, 1000)
state = RecordingState.Recording
}
function pauseRecording (): void {
state = RecordingState.Paused
clearInterval(timer)
recorder?.pause()
}
async function stopRecording (): Promise<void> {
state = RecordingState.Paused
clearInterval(timer)
await recorder?.stop()
distpacher('close', recordingId)
}
async function cancelRecording (): Promise<void> {
await recorder?.cancel()
distpacher('close', true)
}
async function deleteRecording (): Promise<void> {
state = RecordingState.Inactive
await recorder?.cancel()
clearInterval(timer)
time = '0:00'
seconds = 0
}
function toggleExpand (): void {
expanded = !expanded
}
return () => {
clearInterval(timer)
}
})
</script>
{#if state === RecordingState.Inactive}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control-panel">
<span class="control-button play" on:click={startRecording}>
<Record size="medium" />
<Label label={plugin.string.Record} />
</span>
<span class="control-button" on:click={cancelRecording}>
<Label label={plugin.string.Cancel} />
</span>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control-panel {!expanded ? 'collapsed' : ''}">
{#if expanded}
<span class="control-button {state === RecordingState.Recording ? 'stop' : ''}" on:click={stopRecording}>
<Stop size="small" />
</span>
<div class="buttons-panel" class:pointer-events-none={dragging}>
<!-- Recording not started -->
{#if state == null}
<Button icon={IconRecord} kind={'primary'} label={plugin.string.Record} noFocus on:click={handleStartRecording} />
{#if state === RecordingState.Recording}
<span class="control-button" on:click={pauseRecording}>
<Pause size="medium" />
</span>
{:else}
<span class="control-button play" on:click={startRecording}>
<Play size="small" />
</span>
{/if}
<Button
icon={isMicEnabled ? media.icon.Mic : media.icon.MicOff}
kind={'icon'}
showTooltip={{ label: isMicEnabled ? media.string.TurnOffMic : media.string.TurnOnMic, direction }}
noFocus
on:click={handleToggleMic}
/>
<Button
icon={IconClose}
kind={'icon'}
showTooltip={{ label: plugin.string.Cancel, direction }}
noFocus
on:click={handleCancelRecording}
/>
{:else}
<Button
icon={IconStop}
kind={state.state === 'recording' ? 'dangerous' : 'icon'}
showTooltip={{ label: plugin.string.Stop, direction }}
disabled={state.state === 'stopped'}
noFocus
on:click={handleStopRecording}
/>
<div class="divider" />
{#if state.state === 'recording'}
<Button
icon={IconPause}
kind={'icon'}
showTooltip={{ label: plugin.string.Pause, direction }}
noFocus
on:click={handlePauseRecording}
/>
{:else if state.state === 'paused'}
<Button
icon={IconPlay}
kind={'primary'}
showTooltip={{ label: plugin.string.Resume, direction }}
noFocus
on:click={handleResumeRecording}
/>
{/if}
<span class="timer">{time}</span>
{#if expanded}
<span class="control-button" on:click={deleteRecording}>
<Trash size="medium" />
</span>
{/if}
<div
class="timer font-medium"
class:content-color={state.state === 'recording'}
class:content-dark-color={state.state !== 'recording'}
>
{formatElapsedTime(elapsedTime)}
</div>
<span class="control-button expand-toggle" on:click={toggleExpand}>
{#if expanded}
<Collapse size="small" />
{:else}
<Expand size="small" />
{/if}
</span>
</div>
{/if}
<Button
icon={isMicEnabled ? media.icon.Mic : media.icon.MicOff}
kind={'icon'}
showTooltip={{ label: isMicEnabled ? media.string.TurnOffMic : media.string.TurnOnMic, direction }}
noFocus
on:click={handleToggleMic}
/>
<div class="divider" />
<Button
icon={IconRestart}
kind={'icon'}
showTooltip={{ label: plugin.string.RestartRecording, direction }}
noFocus
on:click={handleRestartRecording}
/>
<Button
icon={IconDelete}
kind={'icon'}
showTooltip={{ label: plugin.string.Cancel, direction }}
noFocus
on:click={handleDeleteRecording}
/>
{/if}
</div>
<style lang="scss">
.control-button {
padding: 0.5rem 0.5rem;
gap: 0.25rem;
border-left: 0.5px solid var(--button-border-color);
color: var(--theme-text-primary-color);
cursor: pointer;
justify-content: center;
display: flex;
flex-direction: row;
align-items: center;
}
.control-panel {
min-height: 2.8rem;
.buttons-panel {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--theme-recorder-panel-bg);
padding: 0.25rem;
border-radius: 0.5rem;
transition:
max-width 0.4s ease-in-out,
padding 0.3s ease-in-out;
overflow: hidden;
border: 0.5px solid var(--button-border-color);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.collapsed {
max-width: 7.5rem;
}
.expand-toggle {
margin-left: auto;
padding-left: 0.5rem;
}
.stop {
border-radius: 0.5rem;
background-color: red;
color: #ffffffff;
}
.play {
border-radius: 0.5rem;
background-color: var(--primary-button-default);
color: #ffffffff;
border-radius: 0.75rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.375rem;
padding: 0.375rem;
}
.timer {
min-width: 0.4rem;
padding: 0 0.375rem;
min-width: 3.5rem;
text-align: center;
}
// .recording.collapsed .control-panel:not(.expand-toggle) {
// opacity: 0;
// transform: scale(0.8);
// pointer-events: none;
// }
.divider {
width: 1px;
background-color: var(--theme-divider-color);
align-self: stretch;
}
</style>
@@ -14,61 +14,177 @@
-->
<script lang="ts">
import Camera from './Camera.svelte'
import Panel from './Panel.svelte'
import { createEventDispatcher } from 'svelte'
import media, { getCameraStream, getSelectedCamId, getSelectedMicId } from '@hcengineering/media'
import { useMedia } from '@hcengineering/media-resources'
import { InvertedTheme } from '@hcengineering/theme'
import { Button } from '@hcengineering/ui'
import type { FileUploadCallback } from '@hcengineering/uploader'
import { onDestroy } from 'svelte'
const dispatch = createEventDispatcher()
const pos = { x: 0, y: 0, offsetX: 0, offsetY: 0 }
import { type CameraSize } from '../types'
import Camera from './Camera.svelte'
import Draggable from './Draggable.svelte'
import Panel from './Panel.svelte'
export let onFileUploaded: FileUploadCallback | undefined
export function canClose (): boolean {
// Avoid closing the camera when Esc is pressed
// Consider allowing closing the popup when recording not started
return false
}
let size = (localStorage.getItem('recorder.camera.size') as CameraSize) ?? 'medium'
$: localStorage.setItem('recorder.camera.size', size)
let useCamera = true
let isCamEnabled = true
let isMicEnabled = true
let camDeviceId = getSelectedCamId()
let micDeviceId = getSelectedMicId()
let streamPromise = Promise.resolve()
let stream: MediaStream | null = null
const session = useMedia({
state: {
camera: { enabled: isCamEnabled },
microphone: { enabled: isMicEnabled }
}
})
session.on('camera', (enabled) => {
isCamEnabled = enabled
})
session.on('microphone', (enabled) => {
isMicEnabled = enabled
})
session.on('selected-camera', (deviceId) => {
camDeviceId = deviceId
})
session.on('selected-microphone', (deviceId) => {
micDeviceId = deviceId
})
function enableCamera (stream: MediaStream | null, useCamera: boolean): void {
if (useCamera) {
session.setCamera({ enabled: isCamEnabled })
} else {
session.setCamera(undefined)
}
}
function enableAudioTracks (stream: MediaStream | null, enabled: boolean): void {
if (stream != null) {
const tracks = stream.getAudioTracks()
for (const track of tracks) {
track.enabled = enabled
}
session.setMicrophone({ enabled })
}
}
function enableVideoTracks (stream: MediaStream | null, enabled: boolean): void {
if (stream != null) {
const tracks = stream.getVideoTracks()
for (const track of tracks) {
track.enabled = enabled
}
session.setCamera(useCamera ? { enabled } : undefined)
}
}
async function releaseStream (stream: MediaStream | null): Promise<void> {
if (stream !== null) {
stream.getTracks().forEach((track) => {
track.stop()
})
}
}
async function updateStream (
camDeviceId: string | undefined,
micDeviceId: string | undefined,
useCamera: boolean
): Promise<void> {
await releaseStream(stream)
if (!useCamera) return
try {
// TODO we should request both audio and video tracks depending on enabled state
stream = await getCameraStream(camDeviceId)
} catch (err) {
console.warn(err)
stream = null
}
}
$: streamPromise = updateStream(camDeviceId, micDeviceId, useCamera)
$: enableCamera(stream, useCamera)
$: enableAudioTracks(stream, isMicEnabled)
$: enableVideoTracks(stream, isCamEnabled)
onDestroy(async () => {
await streamPromise
await releaseStream(stream)
})
let posX = 0
let posY = 0
let dragging = false
function startDrag (event: MouseEvent): void {
dragging = true
pos.offsetX = event.clientX - pos.x
pos.offsetY = event.clientY - pos.y
// Tooltip direction based on the popup position
let direction: 'top' | 'bottom' = 'bottom'
$: direction = posY > 0 ? 'top' : 'bottom'
function handleOpenCamera (): void {
useCamera = true
}
function stopDrag (): void {
dragging = false
}
function onClose (res: any): void {
dispatch('close', res.detail)
}
function handleDrag (event: MouseEvent): void {
if (dragging) {
pos.x = event.clientX - pos.offsetX
pos.y = event.clientY - pos.offsetY
}
function handleCloseCamera (): void {
useCamera = false
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="container"
style="left: {pos.x}px; top: {pos.y}px;"
on:mousedown={startDrag}
on:mousemove={handleDrag}
on:mouseup={stopDrag}
on:mouseleave={stopDrag}
>
<Camera />
<Panel on:close={onClose} />
</div>
<Draggable bind:dragging bind:posX bind:posY key={'recorder.popup.position'}>
<InvertedTheme>
<div class="container">
{#if useCamera}
<Camera bind:size {stream} {isCamEnabled} on:close={handleCloseCamera} />
{:else}
<div class="buttons-panel" class:pointer-events-none={dragging}>
<Button icon={media.icon.Cam} kind={'icon'} noFocus on:click={handleOpenCamera} />
</div>
{/if}
<Panel bind:isMicEnabled {dragging} {direction} {onFileUploaded} cameraStream={stream} on:close />
</div>
</InvertedTheme>
</Draggable>
<style lang="scss">
.container {
display: flex;
flex-direction: row;
align-items: flex-end;
position: absolute;
cursor: grab;
user-select: none;
gap: 0.5rem;
padding: 0.5rem;
padding: 1rem;
transition: all 0.5s ease;
}
.container:active {
cursor: grabbing;
.buttons-panel {
display: flex;
align-items: center;
border-radius: 0.75rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.375rem;
padding: 0.375rem;
}
</style>
@@ -0,0 +1,61 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getMetadata } from '@hcengineering/platform'
import { getEventPopupPositionElement, Icon, showPopup } from '@hcengineering/ui'
import IconRec from './icons/Rec.svelte'
import IconRecordOn from './icons/RecordOn.svelte'
import RecordingPopup from './RecordingPopup.svelte'
import plugin from '../plugin'
import { record } from '../recording'
import { recording } from '../stores'
import { createRecordingInDrive } from '../utils'
const endpoint = getMetadata(plugin.metadata.StreamUrl) ?? ''
function handleStartRecording (): void {
record({
onFileUploaded: createRecordingInDrive
})
}
function handleShowRecording (ev: MouseEvent): void {
showPopup(RecordingPopup, {}, getEventPopupPositionElement(ev))
}
</script>
{#if endpoint !== ''}
{#if $recording !== null}
<button
class="antiButton ghost jf-center bs-none no-focus statusButton negative"
style="padding-left: 0.25rem"
on:click={handleShowRecording}
>
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--primary-button-color)' }} size="small" />
<Icon icon={IconRec} iconProps={{ fill: 'var(--primary-button-color)' }} size="small" />
</button>
{:else}
<button
class="antiButton ghost jf-center bs-none no-focus statusButton"
style="padding-left: 0.25rem"
on:click={handleStartRecording}
>
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--theme-state-negative-color)' }} size="small" />
<Icon icon={IconRec} iconProps={{ fill: 'var(--theme-dark-color)' }} size="small" />
</button>
{/if}
{/if}
@@ -0,0 +1,83 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Button, IconStop } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../plugin'
import { stopRecording } from '../recording'
import { recording } from '../stores'
const dispatch = createEventDispatcher()
let video: HTMLVideoElement | null = null
$: if (video != null) {
video.srcObject = $recording?.stream ?? null
}
async function handleStop (): Promise<void> {
await stopRecording()
dispatch('close')
}
</script>
<div class="antiPopup antiPopup-withHeader thinStyle">
<div class="ap-space" />
<div class="ap-header">
<div class="ap-space x2" />
<div class="preview">
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} width="100%" height="100%" autoplay muted />
</div>
</div>
<div class="ap-space" />
<div class="ap-scroll">
<div class="ap-box">
<div class="p-2 flex-row-center justify-end flex-grow">
<Button
label={plugin.string.Stop}
kind={'negative'}
icon={IconStop}
iconProps={{ size: 'large' }}
padding={'0 .5rem 0 .25rem'}
on:click={handleStop}
/>
</div>
<div class="ap-space" />
</div>
</div>
</div>
<style lang="scss">
.icon {
padding: 0.25rem;
}
.preview {
border-radius: 0.25rem;
border: 1px solid var(--theme-divider-color);
min-width: 15rem;
max-width: 20rem;
}
video {
border-radius: inherit;
}
</style>
@@ -0,0 +1,14 @@
<script lang="ts">
import { pushRootBarComponent } from '@hcengineering/ui'
import { onMount } from 'svelte'
import recorder from '../plugin'
let parentElement: HTMLDivElement
onMount(() => {
pushRootBarComponent('right', recorder.component.RecorderExt, 1300)
})
</script>
<div bind:this={parentElement} class="hidden"></div>
@@ -18,8 +18,10 @@
export let fill: string = 'currentColor'
</script>
<svg width="32" height="32" viewBox="0 0 32 32" class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg">
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 10.0002C4 8.34339 5.34315 7.00024 7 7.00024H19C20.6569 7.00024 22 8.34339 22 10.0002V22.0002C22 23.6571 20.6569 25.0002 19 25.0002H7C5.34315 25.0002 4 23.6571 4 22.0002V10.0002ZM7 5.00024C4.23858 5.00024 2 7.23882 2 10.0002V22.0002C2 24.7617 4.23858 27.0002 7 27.0002H19C21.7614 27.0002 24 24.7617 24 22.0002V20.9991L27.5997 23.7002C28.5885 24.4422 30 23.7366 30 22.5004V9.51017C30 8.27509 28.5908 7.56925 27.6017 8.30888L24 11.0021V10.0002C24 7.23882 21.7614 5.00024 19 5.00024H7ZM24 13.4994L28 10.5084V21.5001L24 18.4987V13.4994Z"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z"
/>
</svg>
@@ -1,25 +0,0 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg width="32" height="32" viewBox="0 0 32 32" class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg">
<path
d="M17 29C17 29.5523 16.5523 30 16 30C15.4477 30 15 29.5523 15 29V23.9807C11.6703 23.8517 8.63508 23.0825 6.33943 21.8662C3.86041 20.5527 2 18.5666 2 16.1121C2 14.3994 2.91858 12.9036 4.29003 11.7304C5.66135 10.5573 7.55607 9.63747 9.73381 9.03609C10.2662 8.88908 10.8169 9.20146 10.9639 9.73382C11.1109 10.2662 10.7985 10.8169 10.2662 10.9639C8.28415 11.5113 6.67887 12.3188 5.59014 13.2502C4.50153 14.1814 4 15.1673 4 16.1121C4 17.4616 5.04974 18.9195 7.2758 20.0989C9.24478 21.1422 11.948 21.8509 15 21.9791L15 3C15 2.44771 15.4477 2 16 2C16.5523 2 17 2.44772 17 3L17 21.9791C20.052 21.8509 22.7552 21.1422 24.7242 20.0989C26.9503 18.9195 28 17.4616 28 16.1121C28 15.1673 27.4985 14.1814 26.4099 13.2502C25.5635 12.5261 24.405 11.877 23 11.3667V15H21V9.00001H29V11H26.7411C27.0872 11.231 27.411 11.4747 27.71 11.7304C29.0814 12.9036 30 14.3994 30 16.1121C30 18.5666 28.1396 20.5527 25.6606 21.8662C23.3649 23.0825 20.3297 23.8517 17 23.9807V29Z"
/>
</svg>
@@ -0,0 +1,31 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg {fill} width="22" height="10" viewBox="0 0 22 10" xmlns="http://www.w3.org/2000/svg">
<path
d="M18.4162 9.33404C17.8642 9.33404 17.3642 9.24604 16.9162 9.07004C16.4762 8.88604 16.0962 8.61404 15.7762 8.25404C15.4642 7.89404 15.2242 7.45004 15.0562 6.92204C14.8882 6.38604 14.8042 5.76604 14.8042 5.06204C14.8042 4.35804 14.8882 3.73404 15.0562 3.19004C15.2242 2.63804 15.4642 2.17804 15.7762 1.81004C16.0962 1.43404 16.4762 1.15004 16.9162 0.958044C17.3642 0.766044 17.8642 0.670044 18.4162 0.670044C19.1682 0.670044 19.7922 0.830044 20.2882 1.15004C20.7842 1.47004 21.1802 1.95404 21.4762 2.60204L20.1202 3.32204C20.0002 2.94604 19.8042 2.64604 19.5322 2.42204C19.2602 2.19004 18.8882 2.07404 18.4162 2.07404C17.8242 2.07404 17.3522 2.27404 17.0002 2.67404C16.6562 3.07404 16.4842 3.63404 16.4842 4.35404V5.67404C16.4842 6.40204 16.6562 6.96204 17.0002 7.35404C17.3522 7.73804 17.8242 7.93004 18.4162 7.93004C18.8882 7.93004 19.2722 7.80204 19.5682 7.54604C19.8722 7.29004 20.0922 6.97404 20.2282 6.59804L21.5122 7.35404C21.2082 7.97804 20.8042 8.46604 20.3002 8.81804C19.7962 9.16204 19.1682 9.33404 18.4162 9.33404Z"
/>
<path
d="M7.96875 9.19009V0.814087H13.4888V2.21809H9.55275V4.24609H13.0328V5.65009H9.55275V7.78609H13.4888V9.19009H7.96875Z"
/>
<path
d="M1.584 9.19009H0V0.814087H3.78C4.164 0.814087 4.508 0.874087 4.812 0.994087C5.116 1.11409 5.372 1.29009 5.58 1.52209C5.796 1.74609 5.96 2.01809 6.072 2.33809C6.192 2.65809 6.252 3.01409 6.252 3.40609C6.252 3.98209 6.12 4.47809 5.856 4.89409C5.6 5.31009 5.212 5.60609 4.692 5.78209L6.408 9.19009H4.644L3.084 5.96209H1.584V9.19009ZM3.624 4.63009C3.928 4.63009 4.168 4.55009 4.344 4.39009C4.52 4.22209 4.608 3.98609 4.608 3.68209V3.13009C4.608 2.82609 4.52 2.59409 4.344 2.43409C4.168 2.27409 3.928 2.19409 3.624 2.19409H1.584V4.63009H3.624Z"
/>
</svg>
@@ -0,0 +1,25 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.8332 7.99996C10.8332 6.43515 9.56464 5.16663 7.99984 5.16663C6.43503 5.16663 5.1665 6.43515 5.1665 7.99996C5.1665 9.56477 6.43503 10.8333 7.99984 10.8333C9.56464 10.8333 10.8332 9.56477 10.8332 7.99996ZM11.8332 7.99996C11.8332 10.1171 10.1169 11.8333 7.99984 11.8333C5.88275 11.8333 4.1665 10.1171 4.1665 7.99996C4.1665 5.88287 5.88275 4.16663 7.99984 4.16663C10.1169 4.16663 11.8332 5.88287 11.8332 7.99996Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79086 10.2091 4 8 4C5.79086 4 4 5.79086 4 8C4 10.2091 5.79086 12 8 12Z"
/>
</svg>
+7
View File
@@ -16,9 +16,16 @@
import { type Resources } from '@hcengineering/platform'
import { record } from './recording'
import RecorderExt from './components/RecorderExt.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
export { ScreenRecorder } from './screen-recorder'
export default async (): Promise<Resources> => ({
component: {
RecorderExt,
WorkbenchExtension
},
function: {
Record: record
}
+21 -2
View File
@@ -13,7 +13,26 @@
// limitations under the License.
//
import { mergeIds } from '@hcengineering/platform'
import { type IntlString, mergeIds } from '@hcengineering/platform'
import plugin, { recorderId } from '@hcengineering/recorder'
import { type AnyComponent } from '@hcengineering/ui/src/types'
export default mergeIds(recorderId, plugin, {})
export default mergeIds(recorderId, plugin, {
component: {
RecorderExt: '' as AnyComponent
},
string: {
Pause: '' as IntlString,
Stop: '' as IntlString,
Resume: '' as IntlString,
Record: '' as IntlString,
Cancel: '' as IntlString,
Restart: '' as IntlString,
CancelRecording: '' as IntlString,
CancelRecordingConfirm: '' as IntlString,
RestartRecording: '' as IntlString,
RestartRecordingConfirm: '' as IntlString,
RecordingTitle: '' as IntlString,
ClickToSkip: '' as IntlString
}
})
+65 -28
View File
@@ -15,16 +15,29 @@
import { ChunkReader } from './stream'
const chunkIntervalMs = 1000
export interface RecorderOptions {
mediaStream: MediaStream
}
export class Recorder {
private readonly mediaRecorder: MediaRecorder
private readonly chunkStream: ChunkReader
private readonly mediaStream: MediaStream
// Total elapsed recording time in milliseconds (excluding pauses)
private elapsedMs: number = 0
// Timestamp when recording or resuming started; null if paused or not started
private lastStartTime: number | null = null
constructor (options: RecorderOptions) {
const { mediaStream } = options
constructor (mediaStream: MediaStream) {
const supportedTypes = getSupportedMimeTypes()
if (supportedTypes.length === 0) {
throw new Error('video recording is not supported')
throw new Error('Recorder: video recording is not supported')
}
const mimeType = supportedTypes[0]
this.mediaStream = mediaStream
this.chunkStream = new ChunkReader()
@@ -32,10 +45,15 @@ export class Recorder {
this.mediaRecorder.ondataavailable = async (e) => {
this.chunkStream.push(e.data)
}
this.mediaRecorder.onstart = () => {
console.debug('Recorder: MediaRecorder started', new Date().toISOString())
}
this.mediaRecorder.onstop = () => {
console.debug('Recorder: MediaRecorder stopped', new Date().toISOString())
this.chunkStream.close()
}
this.mediaRecorder.onerror = (ev) => {
console.error('Recorder: MediaRecorder error:', ev)
this.chunkStream.close()
}
}
@@ -45,12 +63,16 @@ export class Recorder {
}
public start (): void {
const readIntervalMs = 200
this.mediaRecorder.start(readIntervalMs)
this.elapsedMs = 0
this.lastStartTime = Date.now()
this.mediaRecorder.start(chunkIntervalMs)
console.debug('Recorder: recording started', new Date().toISOString())
}
public resume (): void {
this.lastStartTime = Date.now()
this.mediaRecorder.resume()
console.debug('Recorder: recording resumed', new Date().toISOString())
}
public asStream (): ChunkReader {
@@ -58,20 +80,40 @@ export class Recorder {
}
public stop (): void {
for (const track of this.mediaStream.getTracks()) {
track.stop()
if (this.lastStartTime !== null) {
this.elapsedMs += Date.now() - this.lastStartTime
this.lastStartTime = null
}
this.mediaRecorder.stop()
try {
this.mediaRecorder.stop()
} catch (err) {
console.error('Recorder: error stopping MediaRecorder:', err)
} finally {
for (const track of this.mediaStream.getTracks()) {
track.stop()
}
}
console.debug('Recorder: recording stopped, duration:', this.getRecordedTimeMs(), 'ms')
}
public pause (): void {
if (this.lastStartTime !== null) {
this.elapsedMs += Date.now() - this.lastStartTime
this.lastStartTime = null
}
this.mediaRecorder.pause()
console.debug('Recorder: recording paused, duration:', this.getRecordedTimeMs(), 'ms')
}
public getRecordedTimeMs (): number {
return this.lastStartTime !== null ? this.elapsedMs + Date.now() - this.lastStartTime : this.elapsedMs
}
}
function getSupportedMimeTypes (): string[] {
const videoTypes = ['mp4', 'webm', 'ogg', 'x-matroska']
const videoCodecs = [
const containers = ['mp4', 'webm', 'ogg', 'x-matroska']
const codecs = [
'vp9,opus',
'vp8,opus',
'vp9',
@@ -86,24 +128,19 @@ function getSupportedMimeTypes (): string[] {
'h.265'
]
const supportedTypes: string[] = []
videoTypes.forEach((videoType) => {
const type = `video/${videoType}`
videoCodecs.forEach((codec) => {
const variations = [
`${type}codecs=${codec}`,
`${type}codecs:${codec}`,
`${type}codecs=${codec.toUpperCase()}`,
`${type}codecs:${codec.toUpperCase()}`,
`${type}`
]
for (const variation of variations) {
if (MediaRecorder.isTypeSupported(variation)) {
supportedTypes.push(variation)
break
}
const supported: string[] = []
for (const container of containers) {
const base = `video/${container}`
for (const codec of codecs) {
const mime = `${base};codecs="${codec}"`
if (MediaRecorder.isTypeSupported(mime)) {
supported.push(mime)
break
}
})
})
return supportedTypes
}
if (MediaRecorder.isTypeSupported(base)) {
supported.push(base)
}
}
return supported
}
+163 -33
View File
@@ -13,32 +13,31 @@
// limitations under the License.
//
import { type FileUploadOptions, type FileUploadCallback } from '@hcengineering/uploader'
import Recorder from './components/Recorder.svelte'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
import { type Blob, type Ref } from '@hcengineering/core'
import { getBlobUrl } from '@hcengineering/presentation'
import { type FileUploadOptions } from '@hcengineering/uploader'
import { get } from 'svelte/store'
let recorderOppened = false
import Recorder from './components/Recorder.svelte'
import plugin from './plugin'
import { recorder, recording } from './stores'
import { createScreenRecorder } from './screen-recorder'
export async function record (options: FileUploadOptions): Promise<void> {
if (recorderOppened) {
return
}
if (options.onFileUploaded === undefined) {
return
}
recorderOppened = true
const onUploaded = options.onFileUploaded
showPopup(
export function record (options: FileUploadOptions): void {
const current = get(recorder)
if (current !== null) return
const { onFileUploaded } = options
const popup = showPopup(
Recorder,
{},
{
onFileUploaded
},
undefined,
async (status: any) => {
if (typeof status === 'string') {
await uploadRecording(status, onUploaded)
}
recorderOppened = false
() => {
recorder.set(null)
},
undefined,
{
@@ -47,21 +46,152 @@ export async function record (options: FileUploadOptions): Promise<void> {
fixed: true
}
)
recorder.set(popup)
}
async function uploadRecording (recordingName: string, onUploaded: FileUploadCallback): Promise<void> {
await onUploaded({
uuid: getBlobUrl(recordingName) as Ref<Blob>,
name: 'Recording-' + now(),
file: { ...new Blob(), type: 'video/x-mpegURL' },
type: 'video/x-mpegURL',
path: undefined,
metadata: undefined,
navigateOnUpload: true
export interface RecordingOptions {
cameraStream: MediaStream | null
microphoneStream: MediaStream | null
fps?: number
onSuccess?: (uploadId: string) => Promise<void>
onError?: (uploadId: string) => void
}
export async function startRecording (options: RecordingOptions): Promise<void> {
const current = get(recording)
if (current !== null) {
console.warn('Recording already started', current)
return
}
const { cameraStream, microphoneStream, fps } = options
let displayStream: MediaStream
try {
displayStream = await navigator.mediaDevices.getDisplayMedia({
video: {
frameRate: { ideal: fps ?? 30 }
}
})
for (const track of displayStream.getVideoTracks()) {
track.onended = () => {
void stopRecording()
}
}
} catch (err: any) {
if (err.name === 'NotAllowedError') {
console.debug('User denied screen capture permission', err)
} else {
console.error('Failed to get display media', err)
}
return
}
const mediaStream = makeCombinedStream(displayStream, cameraStream, microphoneStream)
const token = getMetadata(presentation.metadata.Token) ?? ''
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
const endpoint = getMetadata(plugin.metadata.StreamUrl) ?? ''
let width = 0
let height = 0
mediaStream.getVideoTracks().forEach((track) => {
width = Math.max(track.getSettings().width ?? width, width)
height = Math.max(track.getSettings().height ?? height, height)
})
// Callback invoked when upload succeeds, passing blobId
const onSuccess = (blobId: string): void => {
console.debug('Recording upload success', { blobId })
void options.onSuccess?.(blobId)
}
// Callback invoked when upload fails, passing error
const onError = (error: any): void => {
console.error('Recording upload failed', error)
options.onError?.(error)
}
const recorder = createScreenRecorder(mediaStream, { token, endpoint, workspace, width, height, onSuccess, onError })
await recorder.start()
recording.set({ recorder, stream: recorder.stream, state: 'recording' })
}
function now (): string {
const date = new Date()
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
export async function stopRecording (): Promise<void> {
const current = get(recording)
if (current !== null && current.state === 'recording') {
recording.set({ ...current, state: 'stopped' })
await current.recorder.stop()
recording.set(null)
get(recorder)?.close()
} else {
console.warn('Recording is not in `recording` state', current)
}
}
export async function pauseRecording (): Promise<void> {
const current = get(recording)
if (current !== null && current.state === 'recording') {
await current.recorder.pause()
recording.set({ ...current, state: 'paused' })
} else {
console.warn('Recording is not in `recording` state', current)
}
}
export async function restartRecording (): Promise<void> {
const current = get(recording)
if (current !== null) {
await current.recorder.cancel()
await current.recorder.start()
recording.set({ ...current, state: 'recording' })
} else {
console.warn('Recording is not in `paused` state', current)
}
}
export async function resumeRecording (): Promise<void> {
const current = get(recording)
if (current !== null && current.state === 'paused') {
await current.recorder.resume()
recording.set({ ...current, state: 'recording' })
} else {
console.warn('Recording is not in `paused` state', current)
}
}
export async function cancelRecording (): Promise<void> {
const current = get(recording)
if (current !== null) {
await current.recorder.cancel()
recording.set(null)
}
}
function makeCombinedStream (
screenStream: MediaStream,
cameraStream: MediaStream | null,
microphoneStream: MediaStream | null
): MediaStream {
const tracks: MediaStreamTrack[] = []
// Add screen tracks
tracks.push(...screenStream.getTracks())
if (cameraStream != null && cameraStream.getVideoTracks().length > 0) {
// Don't add camera video to the recording, it will just be displayed
// in picture-in-picture style via UI
}
if (cameraStream != null && cameraStream.getAudioTracks().length > 0) {
tracks.push(...cameraStream.getAudioTracks())
}
if (microphoneStream != null && microphoneStream.getAudioTracks().length > 0) {
tracks.push(...microphoneStream.getAudioTracks())
}
return new MediaStream(tracks)
}
@@ -13,63 +13,37 @@
// limitations under the License.
//
import plugin from '@hcengineering/recorder'
import { Recorder } from './recorder'
import { TusUploader, type Uploader, type Options } from './uploader'
import { getMetadata } from '@hcengineering/platform'
import { type Uploader, type TusUploaderOptions, TusUploader } from './uploader'
export function createScreenRecorder (mediaStream: MediaStream, options: TusUploaderOptions): ScreenRecorder {
const recorder = new Recorder({ mediaStream })
const uploader = new TusUploader(recorder.asStream(), options)
return new ScreenRecorder(mediaStream, recorder, uploader)
}
export class ScreenRecorder {
private readonly recorder: Recorder
private readonly uploader: Uploader
constructor (
readonly stream: MediaStream,
private readonly recorder: Recorder,
private readonly uploader: Uploader
) {}
constructor (recorder: Recorder, uploader: Uploader) {
this.recorder = recorder
this.uploader = uploader
get elapsedTime (): number {
return this.recorder.getRecordedTimeMs()
}
static async fromNavigatorMediaDevices (opts: Options): Promise<ScreenRecorder> {
let width = 0
let height = 0
const combinedStream = new MediaStream()
const getMediaStream =
getMetadata(plugin.metadata.GetCustomMediaStream) ??
(async (op) => await navigator.mediaDevices.getDisplayMedia(op))
const displayStream = await getMediaStream({
video: { frameRate: opts.fps ?? 30 }
})
try {
const microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: true })
microphoneStream.getAudioTracks().forEach((track) => {
combinedStream.addTrack(track)
})
} catch (err) {
console.warn('microphone is disabled', err)
}
displayStream.getVideoTracks().forEach((track) => {
combinedStream.addTrack(track)
width = Math.max(track.getSettings().width ?? width, width)
height = Math.max(track.getSettings().height ?? height, height)
})
displayStream.getAudioTracks().forEach((track) => {
combinedStream.addTrack(track)
})
const recorder = new Recorder(combinedStream)
const uploader = new TusUploader(recorder.asStream(), { ...opts, metadata: { resolution: width + ':' + height } })
return new ScreenRecorder(recorder, uploader)
}
public start (): void {
public async start (): Promise<void> {
this.uploader.start()
this.recorder.start()
}
public pause (): void {
public async pause (): Promise<void> {
this.recorder.pause()
}
public resume (): void {
public async resume (): Promise<void> {
this.recorder.resume()
}
+21
View File
@@ -0,0 +1,21 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type PopupResult } from '@hcengineering/ui'
import { writable } from 'svelte/store'
import { type RecordingState } from './types'
export const recorder = writable<PopupResult | null>(null)
export const recording = writable<RecordingState | null>(null)
+10 -2
View File
@@ -21,7 +21,7 @@ export interface ChunkReadResult {
export class ChunkReader {
private readonly chunks: Blob[] = []
private done: boolean = false
private awaitingResolve: ((val: ChunkReadResult | PromiseLike<ChunkReadResult>) => void) | null = null
private awaitingResolve: ((val: ChunkReadResult | Promise<ChunkReadResult>) => void) | null = null
public async read (): Promise<ChunkReadResult> {
if (this.done && this.chunks.length === 0) {
@@ -36,8 +36,16 @@ export class ChunkReader {
}
public push (blob: Blob): void {
if (this.done) {
console.warn('ChunkReader: push into closed stream')
// close just in case someone is waiting for a chunk
if (this.awaitingResolve !== null) {
this.awaitingResolve({ done: true, value: undefined })
}
return
}
this.chunks.push(blob)
console.log(this.awaitingResolve)
if (this.awaitingResolve !== null) {
this.awaitingResolve(this.read())
this.awaitingResolve = null
+22
View File
@@ -0,0 +1,22 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { type ScreenRecorder } from './screen-recorder'
export type CameraSize = 'small' | 'medium' | 'large'
export interface RecordingState {
recorder: ScreenRecorder
stream: MediaStream
state: 'recording' | 'paused' | 'stopped'
}
+53 -30
View File
@@ -16,45 +16,70 @@
import * as tus from 'tus-js-client'
import type { ChunkReader } from './stream'
interface IMap<T> {
[index: string]: T
[index: number]: T
export interface UploadResult {
blobId?: string
error?: any
}
export interface Options {
fps?: number
export interface Uploader {
start: () => void
cancel: () => Promise<void>
wait: () => Promise<UploadResult>
}
export interface TusUploaderOptions {
endpoint: string
token: string
workspace: string
metadata?: IMap<string>
onFinish?: (x: string) => Promise<void>
token: string
width: number
height: number
/** Optional callback invoked on successful upload with generated blobId */
onSuccess?: (blobId: string) => void
/** Optional callback invoked on upload error */
onError?: (error: any) => void
}
export abstract class Uploader {
public abstract start (): void
public abstract cancel (): Promise<void>
public abstract wait (): Promise<void>
}
export class TusUploader extends Uploader {
export class TusUploader implements Uploader {
private readonly upload: tus.Upload
private waiter: (() => void) | null = null
private readonly waiterPromise: Promise<UploadResult>
private waiterResolve: (value: UploadResult) => void = () => {}
constructor (target: ChunkReader, opts: Options) {
super()
this.upload = new tus.Upload(target, {
constructor (reader: ChunkReader, options: TusUploaderOptions) {
const { endpoint, workspace, token, width, height, onSuccess, onError } = options
const resolution = width + ':' + height
this.waiterPromise = new Promise<UploadResult>((resolve) => {
this.waiterResolve = resolve
})
console.debug('TusUploader: uploading', workspace, endpoint)
this.upload = new tus.Upload(reader, {
retryDelays: [0, 1000, 1500, 2000, 2500, 3000],
chunkSize: 2 * 1024 * 1024,
uploadLengthDeferred: true,
endpoint: opts.endpoint,
metadata: { ...opts.metadata, token: opts.token, workspace: opts.workspace },
endpoint,
metadata: { resolution, token, workspace },
onSuccess: () => {
if (this.waiter !== null) {
this.waiter()
}
if (this.upload.url !== null && opts.onFinish !== undefined) {
void opts.onFinish(this.upload.url.substring(this.upload.url.lastIndexOf('/') + 1))
const uploadId = this.upload.url?.split('/').pop()
console.debug('TusUploader: upload success:', uploadId)
if (uploadId === undefined) {
console.error('TusUploader: upload URL does not contain upload ID')
return
}
const blobId = uploadId + '_master.m3u8'
// invoke callback if provided
onSuccess?.(blobId)
this.waiterResolve({ blobId })
},
onError: (error) => {
console.error('TusUploader: upload failed:', error)
// invoke error callback if provided
onError?.(error)
this.waiterResolve({ error })
},
onProgress: (progress) => {
console.debug('TusUploader: upload progress:', progress)
}
})
}
@@ -63,10 +88,8 @@ export class TusUploader extends Uploader {
this.upload.start()
}
public async wait (): Promise<void> {
await new Promise<void>((resolve) => {
this.waiter = resolve
})
public async wait (): Promise<UploadResult> {
return await this.waiterPromise
}
public async cancel (): Promise<void> {
+56
View File
@@ -0,0 +1,56 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import drive, { createFile } from '@hcengineering/drive'
import { translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { type FileUploadCallback, type FileUploadCallbackParams } from '@hcengineering/uploader'
import recorder from './plugin'
export function formatElapsedTime (elapsed: number): string {
const seconds = Math.floor(elapsed / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const displaySeconds = (seconds % 60).toString().padStart(2, '0')
const displayMinutes = (minutes % 60).toString().padStart(2, '0')
return hours > 0 ? `${hours}:${displayMinutes}:${displaySeconds}` : `${displayMinutes}:${displaySeconds}`
}
export async function formatRecordingName (date: Date): Promise<string> {
const timeStr = date.toLocaleTimeString()
const dateStr = date.toLocaleDateString(undefined, {
day: 'numeric',
month: 'long',
year: 'numeric'
})
return await translate(recorder.string.RecordingTitle, { date: dateStr + ' ' + timeStr })
}
export const createRecordingInDrive: FileUploadCallback = async (params: FileUploadCallbackParams): Promise<void> => {
const { uuid, name, file, metadata } = params
const client = getClient()
const data = {
file: uuid,
size: file.size,
type: file.type,
lastModified: file instanceof File ? file.lastModified : Date.now(),
title: name,
metadata
}
await createFile(client, recorder.space.Drive, drive.ids.Root, data)
}
+1
View File
@@ -38,6 +38,7 @@
},
"dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/drive": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/uploader": "^0.6.0",
"tus-js-client": "^4.3.1"
+5 -8
View File
@@ -11,7 +11,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Asset, IntlString, plugin, Plugin, Resource, Metadata } from '@hcengineering/platform'
import { type Ref } from '@hcengineering/core'
import { type Drive } from '@hcengineering/drive'
import { Asset, plugin, Plugin, Resource, Metadata } from '@hcengineering/platform'
import { type UploadHandler } from '@hcengineering/uploader'
/**
@@ -35,13 +37,8 @@ const recordPlugin = plugin(recorderId, {
StreamUrl: '' as Metadata<string>,
GetCustomMediaStream: '' as Metadata<GetMediaStream>
},
string: {
Pause: '' as IntlString,
Stop: '' as IntlString,
Resume: '' as IntlString,
Record: '' as IntlString,
Cancel: '' as IntlString,
ClickToSkip: '' as IntlString
space: {
Drive: '' as Ref<Drive>
},
function: {
Record: '' as Resource<UploadHandler>
-1
View File
@@ -49,7 +49,6 @@
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"install": "^0.13.0",
"stream-browserify": "^3.0.0",
"svelte": "^4.2.19"
}
}