mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-29 19:29:36 +02:00
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:
Generated
+79
-9
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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,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,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"
|
||||
}
|
||||
}
|
||||
@@ -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,6 +5,11 @@
|
||||
"Stop": "停止",
|
||||
"Resume": "再開",
|
||||
"Record": "記録",
|
||||
"CancelRecording": "録音をキャンセル",
|
||||
"CancelRecordingConfirm": "録音をキャンセルしてもよろしいですか?",
|
||||
"RestartRecording": "録音を再開",
|
||||
"RestartRecordingConfirm": "録音を再開してもよろしいですか?",
|
||||
"RecordingTitle": "画面録画 {date}",
|
||||
"ClickToSkip": "クリックしてスキップ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
"Stop": "Стоп",
|
||||
"Resume": "Продолжить",
|
||||
"Record": "Запись",
|
||||
"CancelRecording": "Отменить запись",
|
||||
"CancelRecordingConfirm": "Вы уверены, что хотите отменить запись?",
|
||||
"RestartRecording": "Перезапустить запись",
|
||||
"RestartRecordingConfirm": "Вы уверены, что хотите перезапустить запись?",
|
||||
"RecordingTitle": "Запись экрана {date}",
|
||||
"ClickToSkip": "Нажмите для пропуска"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@
|
||||
"Stop": "停止",
|
||||
"Resume": "继续",
|
||||
"Record": "录制",
|
||||
"CancelRecording": "取消录制",
|
||||
"CancelRecordingConfirm": "您确定要取消录制吗?",
|
||||
"RestartRecording": "重新开始录制",
|
||||
"RestartRecordingConfirm": "您确定要重新开始录制吗?",
|
||||
"RecordingTitle": "屏幕录制 {date}",
|
||||
"ClickToSkip": "点击跳过"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+4
-2
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user