UBERF-9731 Recorder initial version (#8800)

* UBERF-9731 Recorder initial version

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

* fix: rush validate issues

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

---------

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2025-05-01 23:46:35 +07:00
committed by GitHub
parent 3c4057ed89
commit 8a9e04b8d4
58 changed files with 1679 additions and 547 deletions
@@ -14,125 +14,159 @@
-->
<script lang="ts">
import { onDestroy } from 'svelte'
import Camera from './icons/Camera.svelte'
import Close from './icons/Close.svelte'
import { getCurrentEmployee } from '@hcengineering/contact'
import { Button, IconClose } from '@hcengineering/ui'
import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
import { createEventDispatcher } from 'svelte'
let showButtons = false
let showCamera = false
import IconCircleLarge from './icons/CircleLarge.svelte'
import IconCircleMedium from './icons/CircleMedium.svelte'
import IconCircleSmall from './icons/CircleSmall.svelte'
let videoElement: HTMLVideoElement
let stream: MediaStream | null = null
export let stream: MediaStream | null
export let isCamEnabled: boolean = true
export let size: 'small' | 'medium' | 'large' = 'medium'
const stopCamera = (): void => {
if (stream === null) {
return
}
stream.getTracks().forEach((track) => {
track.stop()
})
videoElement.srcObject = null
stream = null
showCamera = false
const me = getCurrentEmployee()
const meName = $personByIdStore.get(me)?.name
const meAvatar = $personByIdStore.get(me)
const dispatch = createEventDispatcher()
let video: HTMLVideoElement
$: if (video != null && video.srcObject !== stream) {
video.srcObject = stream
}
const startCamera = async (): Promise<void> => {
stopCamera()
showCamera = true
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { frameRate: { ideal: 30 } } })
videoElement.srcObject = stream
} catch (err) {
console.log(err)
}
function handleCloseCamera (): void {
dispatch('close')
}
onDestroy(async (): Promise<void> => {
stopCamera()
})
function handleCameraSizeSmall (): void {
size = 'small'
}
function handleCameraSizeMedium (): void {
size = 'medium'
}
function handleCameraSizeLarge (): void {
size = 'large'
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="movable"
on:mouseenter={() => {
showButtons = true
}}
on:mouseleave={() => {
showButtons = false
}}
>
{#if showCamera}
<video class="video video-container" bind:this={videoElement} autoplay muted playsinline> </video>
{#if showButtons}
<div
class="camera-control"
on:click={() => {
stopCamera()
}}
>
<Close size="full" />
</div>
{/if}
<div class="container {size}">
{#if stream != null && isCamEnabled}
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} autoplay muted playsinline />
{:else}
<div class="control">
<span
class="control-button"
on:click={() => {
startCamera()
}}
>
<Camera size="medium" />
</span>
</div>
<Avatar variant={'circle'} size={'full'} name={meName} person={meAvatar} showStatus={false} adaptiveName />
{/if}
<div class="controls">
<div class="buttons-panel">
<Button icon={IconClose} kind={'icon'} size={'small'} noFocus on:click={handleCloseCamera} />
<div class="divider" />
<Button
icon={IconCircleSmall}
kind={'icon'}
size={'small'}
selected={size === 'small'}
noFocus
on:click={handleCameraSizeSmall}
/>
<Button
icon={IconCircleMedium}
kind={'icon'}
size={'small'}
selected={size === 'medium'}
noFocus
on:click={handleCameraSizeMedium}
/>
<Button
icon={IconCircleLarge}
kind={'icon'}
size={'small'}
selected={size === 'large'}
noFocus
on:click={handleCameraSizeLarge}
/>
</div>
</div>
</div>
<style lang="scss">
.video-container {
width: 350px;
height: 350px;
transform: rotateY(180deg);
.container {
position: relative;
transition: all 0.2s ease;
video {
border-radius: 50%;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-bg-color);
transform: rotateY(180deg);
}
&.small {
width: 12rem;
height: 12rem;
}
&.medium {
width: 18rem;
height: 18rem;
}
&.large {
width: 27rem;
height: 27rem;
}
.controls {
visibility: hidden;
}
&:hover {
.controls {
visibility: visible;
}
}
}
.video {
border-radius: 50%;
object-fit: cover;
}
.control {
min-height: 2.8rem;
min-width: 2.8rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
background: var(--theme-recorder-panel-bg);
padding-left: 0.25rem;
border-radius: 0.5rem;
transition:
max-width 0.4s ease-in-out,
padding 0.3s ease-in-out;
overflow: hidden;
border: 0.5px solid var(--button-border-color);
}
.camera-control {
.controls {
position: absolute;
transform: translateY(-350px);
padding: 0.5rem 0.5rem;
border-radius: 0.5rem;
justify-content: center;
cursor: pointer;
width: 100%;
height: 25%;
bottom: 0;
z-index: auto;
display: flex;
align-items: center;
flex-direction: col;
align-items: start;
justify-content: center;
}
.control-button {
padding: 0.5rem 0.5rem;
border-radius: 0.5rem;
justify-content: center;
cursor: pointer;
.buttons-panel {
display: flex;
flex-direction: row;
align-items: center;
background: var(--theme-recorder-active-button);
border-radius: 0.5rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.125rem;
padding: 0.125rem;
}
.divider {
width: 1px;
background-color: var(--theme-divider-color);
align-self: stretch;
}
</style>
@@ -0,0 +1,153 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { resizeObserver } from '@hcengineering/ui'
import { onMount } from 'svelte'
export let key: string | undefined = undefined
// expected to be bound outside
export let dragging = false
export let posX: number = 0
export let posY: number = 0
// distances to the edges of the screen
let right: number = document.body.clientWidth
let top: number = document.body.clientHeight
let bottom: number = 0
let left: number = 0
// drag start position
let dragStartX = 0
let dragStartY = 0
// drag offset
let dragOffsetX = 0
let dragOffsetY = 0
// window and client sizes
let clientWidth = 0
let clientHeight = 0
let windowClientWidth = document.body.clientWidth
let windowClientHeight = document.body.clientHeight
// On mount, restore saved position
onMount(() => {
if (key === undefined) return
const saved = localStorage.getItem(key)
if (saved != null) {
try {
const { l, r, t, b } = JSON.parse(saved)
if (typeof l === 'number' && typeof r === 'number' && typeof t === 'number' && typeof b === 'number') {
left = l
right = r
top = t
bottom = b
}
} catch {}
}
})
onMount(() => {
const observer = new ResizeObserver(() => {
windowClientWidth = document.body.clientWidth
windowClientHeight = document.body.clientHeight
})
observer.observe(document.body)
return () => {
observer.disconnect()
}
})
function handleDragStart (event: MouseEvent): void {
dragStartX = event.clientX
dragStartY = event.clientY
dragOffsetX = event.clientX - posX
dragOffsetY = event.clientY - posY
window.addEventListener('mousemove', handleDrag)
window.addEventListener('mouseup', handleDragEnd)
}
function handleDragEnd (): void {
window.removeEventListener('mousemove', handleDrag)
window.removeEventListener('mouseup', handleDragEnd)
if (dragging && key !== undefined) {
try {
localStorage.setItem(key, JSON.stringify({ l: left, r: right, t: top, b: bottom }))
} catch {}
}
dragging = false
}
function handleDrag (event: MouseEvent): void {
if (!dragging) {
if (Math.abs(event.clientX - dragStartX) < 5 && Math.abs(event.clientY - dragStartY) < 5) {
return
}
dragging = true
}
const x = event.clientX - dragOffsetX
const y = event.clientY - dragOffsetY
// When dragging, update distances to the edges of the screen
top = Math.max(windowClientHeight / 2 + y, 0)
bottom = Math.max(windowClientHeight / 2 - y - clientHeight, 0)
left = Math.max(windowClientWidth / 2 + x, 0)
right = Math.max(windowClientWidth / 2 - x - clientWidth, 0)
}
// Calculate the popup position based on the distances to the edges of the screen
$: posX = left < right ? left - windowClientWidth / 2 : windowClientWidth / 2 - clientWidth - right
$: posY = top < bottom ? top - windowClientHeight / 2 : windowClientHeight / 2 - clientHeight - bottom
// Position the element based on the distances to the edges of the screen
// If it is closer to the left edge, position it to the left, otherwise position it to the right
// If it is closer to the top edge, position it to the top, otherwise position it to the bottom
$: styleX = left < right ? `left: ${left - windowClientWidth / 2}px;` : `right: ${right - windowClientWidth / 2}px;`
$: styleY = top < bottom ? `top: ${top - windowClientHeight / 2}px;` : `bottom: ${bottom - windowClientHeight / 2}px;`
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="draggable"
class:dragging
style="{styleX} {styleY}"
on:mousedown={handleDragStart}
on:mouseup={handleDragEnd}
on:click={handleDragEnd}
use:resizeObserver={(element) => {
clientWidth = element.clientWidth
clientHeight = element.clientHeight
}}
>
<slot />
</div>
<style lang="scss">
.draggable {
position: absolute;
cursor: grab;
user-select: none;
&.dragging {
cursor: grabbing;
}
}
</style>
@@ -14,55 +14,43 @@
-->
<script lang="ts">
import Record from './icons/Record.svelte'
import Play from './icons/Play.svelte'
import Stop from './icons/Stop.svelte'
import Trash from './icons/Trash.svelte'
import Pause from './icons/Pause.svelte'
import Expand from './icons/Expand.svelte'
import Collapse from './icons/Collapse.svelte'
import { ScreenRecorder } from '../screen-recorder'
import { createEventDispatcher } from 'svelte'
import { showPopup, Label } from '@hcengineering/ui'
import Countdown from './Countdown.svelte'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Blob, type Ref } from '@hcengineering/core'
import media from '@hcengineering/media'
import { MessageBox } from '@hcengineering/presentation'
import { Button, IconClose, IconDelete, closeTooltip, showPopup } from '@hcengineering/ui'
import { type FileUploadCallback } from '@hcengineering/uploader'
import { createEventDispatcher, onMount } from 'svelte'
import plugin from '../plugin'
import {
cancelRecording,
pauseRecording,
restartRecording,
resumeRecording,
startRecording,
stopRecording
} from '../recording'
import { recording } from '../stores'
import { formatElapsedTime, formatRecordingName } from '../utils'
let timer: number | undefined = undefined
let recorder: ScreenRecorder | null = null
let seconds = 0
let recordingId: string | null = null
import Countdown from './Countdown.svelte'
import IconRestart from './icons/Restart.svelte'
import IconPause from './icons/Pause.svelte'
import IconPlay from './icons/Play.svelte'
import IconStop from './icons/Stop.svelte'
import IconRecord from './icons/Record.svelte'
const distpacher = createEventDispatcher()
export let cameraStream: MediaStream | null = null
export let dragging = false
export let direction: 'top' | 'bottom' = 'bottom'
export let onFileUploaded: FileUploadCallback | undefined
enum RecordingState {
Recording = 'recording',
Paused = 'paused',
Inactive = 'inactive'
}
// expected to be bound outside
export let isMicEnabled = true
let state = RecordingState.Inactive
let expanded = true
let time = '0:00'
const dispatch = createEventDispatcher()
function formatTime (s: number): string {
const mins = Math.floor(s / 60)
const secs = s % 60
return `${mins}:${secs < 10 ? '0' : ''}${secs}`
}
async function createScreenRecorder (): Promise<void> {
recorder = await ScreenRecorder.fromNavigatorMediaDevices({
endpoint: getMetadata(plugin.metadata.StreamUrl) ?? '',
token: getMetadata(presentation.metadata.Token) ?? '',
workspace: getMetadata(presentation.metadata.WorkspaceUuid) ?? '',
onFinish: async (x) => {
recordingId = x + '_master.m3u8'
},
fps: 30
})
}
$: state = $recording
async function showCountdown (): Promise<void> {
const showPopupPromise = new Promise<void>((resolve) => {
@@ -84,170 +72,221 @@
await showPopupPromise
}
async function startRecording (): Promise<void> {
if (state === RecordingState.Recording) {
async function handleStartRecording (): Promise<void> {
if (state != null) {
console.warn('Recording already in progress', state)
return
}
if (state === RecordingState.Inactive) {
try {
await createScreenRecorder()
} catch (err) {
console.log('cant create screen recorder', err)
distpacher('close', true)
return
await startRecording({
cameraStream,
microphoneStream: null,
fps: 30,
onSuccess: async (blobId: string) => {
if (onFileUploaded !== undefined) {
const name = await formatRecordingName(new Date())
const blob = new Blob([], { type: 'video/x-mpegURL' })
await onFileUploaded({
uuid: blobId as Ref<Blob>,
name,
type: blob.type,
file: blob,
path: undefined,
metadata: undefined,
navigateOnUpload: true
})
}
},
onError: (error: any) => {
console.error('Recording upload failed', error)
}
})
}
async function handleResumeRecording (): Promise<void> {
await resumeRecording()
}
async function handlePauseRecording (): Promise<void> {
await pauseRecording()
}
async function handleStopRecording (): Promise<void> {
await stopRecording()
dispatch('close')
}
async function handleRestartRecording (): Promise<void> {
await pauseRecording()
showPopup(
MessageBox,
{
label: plugin.string.RestartRecording,
message: plugin.string.RestartRecordingConfirm
},
undefined,
async (restart: boolean) => {
if (restart) {
await restartRecording()
} else {
await resumeRecording()
}
}
)
}
async function handleCancelRecording (): Promise<void> {
await cancelRecording()
dispatch('close', true)
}
async function handleDeleteRecording (): Promise<void> {
await pauseRecording()
showPopup(
MessageBox,
{
label: plugin.string.CancelRecording,
message: plugin.string.CancelRecordingConfirm
},
undefined,
async (restart: boolean) => {
if (restart) {
await cancelRecording()
dispatch('close', true)
} else {
await resumeRecording()
}
}
)
}
function handleToggleMic (): void {
isMicEnabled = !isMicEnabled
}
$: if (dragging) {
closeTooltip()
}
let elapsedTime = 0
onMount(() => {
const timer = setInterval(() => {
if ($recording !== null) {
elapsedTime = $recording.recorder.elapsedTime
}
await showCountdown()
recorder?.start()
} else {
recorder?.resume()
}
timer = setInterval(() => {
seconds++
time = formatTime(seconds)
}, 1000)
state = RecordingState.Recording
}
function pauseRecording (): void {
state = RecordingState.Paused
clearInterval(timer)
recorder?.pause()
}
async function stopRecording (): Promise<void> {
state = RecordingState.Paused
clearInterval(timer)
await recorder?.stop()
distpacher('close', recordingId)
}
async function cancelRecording (): Promise<void> {
await recorder?.cancel()
distpacher('close', true)
}
async function deleteRecording (): Promise<void> {
state = RecordingState.Inactive
await recorder?.cancel()
clearInterval(timer)
time = '0:00'
seconds = 0
}
function toggleExpand (): void {
expanded = !expanded
}
return () => {
clearInterval(timer)
}
})
</script>
{#if state === RecordingState.Inactive}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control-panel">
<span class="control-button play" on:click={startRecording}>
<Record size="medium" />
<Label label={plugin.string.Record} />
</span>
<span class="control-button" on:click={cancelRecording}>
<Label label={plugin.string.Cancel} />
</span>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control-panel {!expanded ? 'collapsed' : ''}">
{#if expanded}
<span class="control-button {state === RecordingState.Recording ? 'stop' : ''}" on:click={stopRecording}>
<Stop size="small" />
</span>
<div class="buttons-panel" class:pointer-events-none={dragging}>
<!-- Recording not started -->
{#if state == null}
<Button icon={IconRecord} kind={'primary'} label={plugin.string.Record} noFocus on:click={handleStartRecording} />
{#if state === RecordingState.Recording}
<span class="control-button" on:click={pauseRecording}>
<Pause size="medium" />
</span>
{:else}
<span class="control-button play" on:click={startRecording}>
<Play size="small" />
</span>
{/if}
<Button
icon={isMicEnabled ? media.icon.Mic : media.icon.MicOff}
kind={'icon'}
showTooltip={{ label: isMicEnabled ? media.string.TurnOffMic : media.string.TurnOnMic, direction }}
noFocus
on:click={handleToggleMic}
/>
<Button
icon={IconClose}
kind={'icon'}
showTooltip={{ label: plugin.string.Cancel, direction }}
noFocus
on:click={handleCancelRecording}
/>
{:else}
<Button
icon={IconStop}
kind={state.state === 'recording' ? 'dangerous' : 'icon'}
showTooltip={{ label: plugin.string.Stop, direction }}
disabled={state.state === 'stopped'}
noFocus
on:click={handleStopRecording}
/>
<div class="divider" />
{#if state.state === 'recording'}
<Button
icon={IconPause}
kind={'icon'}
showTooltip={{ label: plugin.string.Pause, direction }}
noFocus
on:click={handlePauseRecording}
/>
{:else if state.state === 'paused'}
<Button
icon={IconPlay}
kind={'primary'}
showTooltip={{ label: plugin.string.Resume, direction }}
noFocus
on:click={handleResumeRecording}
/>
{/if}
<span class="timer">{time}</span>
{#if expanded}
<span class="control-button" on:click={deleteRecording}>
<Trash size="medium" />
</span>
{/if}
<div
class="timer font-medium"
class:content-color={state.state === 'recording'}
class:content-dark-color={state.state !== 'recording'}
>
{formatElapsedTime(elapsedTime)}
</div>
<span class="control-button expand-toggle" on:click={toggleExpand}>
{#if expanded}
<Collapse size="small" />
{:else}
<Expand size="small" />
{/if}
</span>
</div>
{/if}
<Button
icon={isMicEnabled ? media.icon.Mic : media.icon.MicOff}
kind={'icon'}
showTooltip={{ label: isMicEnabled ? media.string.TurnOffMic : media.string.TurnOnMic, direction }}
noFocus
on:click={handleToggleMic}
/>
<div class="divider" />
<Button
icon={IconRestart}
kind={'icon'}
showTooltip={{ label: plugin.string.RestartRecording, direction }}
noFocus
on:click={handleRestartRecording}
/>
<Button
icon={IconDelete}
kind={'icon'}
showTooltip={{ label: plugin.string.Cancel, direction }}
noFocus
on:click={handleDeleteRecording}
/>
{/if}
</div>
<style lang="scss">
.control-button {
padding: 0.5rem 0.5rem;
gap: 0.25rem;
border-left: 0.5px solid var(--button-border-color);
color: var(--theme-text-primary-color);
cursor: pointer;
justify-content: center;
display: flex;
flex-direction: row;
align-items: center;
}
.control-panel {
min-height: 2.8rem;
.buttons-panel {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--theme-recorder-panel-bg);
padding: 0.25rem;
border-radius: 0.5rem;
transition:
max-width 0.4s ease-in-out,
padding 0.3s ease-in-out;
overflow: hidden;
border: 0.5px solid var(--button-border-color);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.collapsed {
max-width: 7.5rem;
}
.expand-toggle {
margin-left: auto;
padding-left: 0.5rem;
}
.stop {
border-radius: 0.5rem;
background-color: red;
color: #ffffffff;
}
.play {
border-radius: 0.5rem;
background-color: var(--primary-button-default);
color: #ffffffff;
border-radius: 0.75rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.375rem;
padding: 0.375rem;
}
.timer {
min-width: 0.4rem;
padding: 0 0.375rem;
min-width: 3.5rem;
text-align: center;
}
// .recording.collapsed .control-panel:not(.expand-toggle) {
// opacity: 0;
// transform: scale(0.8);
// pointer-events: none;
// }
.divider {
width: 1px;
background-color: var(--theme-divider-color);
align-self: stretch;
}
</style>
@@ -14,61 +14,177 @@
-->
<script lang="ts">
import Camera from './Camera.svelte'
import Panel from './Panel.svelte'
import { createEventDispatcher } from 'svelte'
import media, { getCameraStream, getSelectedCamId, getSelectedMicId } from '@hcengineering/media'
import { useMedia } from '@hcengineering/media-resources'
import { InvertedTheme } from '@hcengineering/theme'
import { Button } from '@hcengineering/ui'
import type { FileUploadCallback } from '@hcengineering/uploader'
import { onDestroy } from 'svelte'
const dispatch = createEventDispatcher()
const pos = { x: 0, y: 0, offsetX: 0, offsetY: 0 }
import { type CameraSize } from '../types'
import Camera from './Camera.svelte'
import Draggable from './Draggable.svelte'
import Panel from './Panel.svelte'
export let onFileUploaded: FileUploadCallback | undefined
export function canClose (): boolean {
// Avoid closing the camera when Esc is pressed
// Consider allowing closing the popup when recording not started
return false
}
let size = (localStorage.getItem('recorder.camera.size') as CameraSize) ?? 'medium'
$: localStorage.setItem('recorder.camera.size', size)
let useCamera = true
let isCamEnabled = true
let isMicEnabled = true
let camDeviceId = getSelectedCamId()
let micDeviceId = getSelectedMicId()
let streamPromise = Promise.resolve()
let stream: MediaStream | null = null
const session = useMedia({
state: {
camera: { enabled: isCamEnabled },
microphone: { enabled: isMicEnabled }
}
})
session.on('camera', (enabled) => {
isCamEnabled = enabled
})
session.on('microphone', (enabled) => {
isMicEnabled = enabled
})
session.on('selected-camera', (deviceId) => {
camDeviceId = deviceId
})
session.on('selected-microphone', (deviceId) => {
micDeviceId = deviceId
})
function enableCamera (stream: MediaStream | null, useCamera: boolean): void {
if (useCamera) {
session.setCamera({ enabled: isCamEnabled })
} else {
session.setCamera(undefined)
}
}
function enableAudioTracks (stream: MediaStream | null, enabled: boolean): void {
if (stream != null) {
const tracks = stream.getAudioTracks()
for (const track of tracks) {
track.enabled = enabled
}
session.setMicrophone({ enabled })
}
}
function enableVideoTracks (stream: MediaStream | null, enabled: boolean): void {
if (stream != null) {
const tracks = stream.getVideoTracks()
for (const track of tracks) {
track.enabled = enabled
}
session.setCamera(useCamera ? { enabled } : undefined)
}
}
async function releaseStream (stream: MediaStream | null): Promise<void> {
if (stream !== null) {
stream.getTracks().forEach((track) => {
track.stop()
})
}
}
async function updateStream (
camDeviceId: string | undefined,
micDeviceId: string | undefined,
useCamera: boolean
): Promise<void> {
await releaseStream(stream)
if (!useCamera) return
try {
// TODO we should request both audio and video tracks depending on enabled state
stream = await getCameraStream(camDeviceId)
} catch (err) {
console.warn(err)
stream = null
}
}
$: streamPromise = updateStream(camDeviceId, micDeviceId, useCamera)
$: enableCamera(stream, useCamera)
$: enableAudioTracks(stream, isMicEnabled)
$: enableVideoTracks(stream, isCamEnabled)
onDestroy(async () => {
await streamPromise
await releaseStream(stream)
})
let posX = 0
let posY = 0
let dragging = false
function startDrag (event: MouseEvent): void {
dragging = true
pos.offsetX = event.clientX - pos.x
pos.offsetY = event.clientY - pos.y
// Tooltip direction based on the popup position
let direction: 'top' | 'bottom' = 'bottom'
$: direction = posY > 0 ? 'top' : 'bottom'
function handleOpenCamera (): void {
useCamera = true
}
function stopDrag (): void {
dragging = false
}
function onClose (res: any): void {
dispatch('close', res.detail)
}
function handleDrag (event: MouseEvent): void {
if (dragging) {
pos.x = event.clientX - pos.offsetX
pos.y = event.clientY - pos.offsetY
}
function handleCloseCamera (): void {
useCamera = false
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="container"
style="left: {pos.x}px; top: {pos.y}px;"
on:mousedown={startDrag}
on:mousemove={handleDrag}
on:mouseup={stopDrag}
on:mouseleave={stopDrag}
>
<Camera />
<Panel on:close={onClose} />
</div>
<Draggable bind:dragging bind:posX bind:posY key={'recorder.popup.position'}>
<InvertedTheme>
<div class="container">
{#if useCamera}
<Camera bind:size {stream} {isCamEnabled} on:close={handleCloseCamera} />
{:else}
<div class="buttons-panel" class:pointer-events-none={dragging}>
<Button icon={media.icon.Cam} kind={'icon'} noFocus on:click={handleOpenCamera} />
</div>
{/if}
<Panel bind:isMicEnabled {dragging} {direction} {onFileUploaded} cameraStream={stream} on:close />
</div>
</InvertedTheme>
</Draggable>
<style lang="scss">
.container {
display: flex;
flex-direction: row;
align-items: flex-end;
position: absolute;
cursor: grab;
user-select: none;
gap: 0.5rem;
padding: 0.5rem;
padding: 1rem;
transition: all 0.5s ease;
}
.container:active {
cursor: grabbing;
.buttons-panel {
display: flex;
align-items: center;
border-radius: 0.75rem;
border: 1px solid var(--button-border-color);
background-color: var(--theme-bg-color);
gap: 0.375rem;
padding: 0.375rem;
}
</style>
@@ -0,0 +1,61 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getMetadata } from '@hcengineering/platform'
import { getEventPopupPositionElement, Icon, showPopup } from '@hcengineering/ui'
import IconRec from './icons/Rec.svelte'
import IconRecordOn from './icons/RecordOn.svelte'
import RecordingPopup from './RecordingPopup.svelte'
import plugin from '../plugin'
import { record } from '../recording'
import { recording } from '../stores'
import { createRecordingInDrive } from '../utils'
const endpoint = getMetadata(plugin.metadata.StreamUrl) ?? ''
function handleStartRecording (): void {
record({
onFileUploaded: createRecordingInDrive
})
}
function handleShowRecording (ev: MouseEvent): void {
showPopup(RecordingPopup, {}, getEventPopupPositionElement(ev))
}
</script>
{#if endpoint !== ''}
{#if $recording !== null}
<button
class="antiButton ghost jf-center bs-none no-focus statusButton negative"
style="padding-left: 0.25rem"
on:click={handleShowRecording}
>
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--primary-button-color)' }} size="small" />
<Icon icon={IconRec} iconProps={{ fill: 'var(--primary-button-color)' }} size="small" />
</button>
{:else}
<button
class="antiButton ghost jf-center bs-none no-focus statusButton"
style="padding-left: 0.25rem"
on:click={handleStartRecording}
>
<Icon icon={IconRecordOn} iconProps={{ fill: 'var(--theme-state-negative-color)' }} size="small" />
<Icon icon={IconRec} iconProps={{ fill: 'var(--theme-dark-color)' }} size="small" />
</button>
{/if}
{/if}
@@ -0,0 +1,83 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Button, IconStop } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../plugin'
import { stopRecording } from '../recording'
import { recording } from '../stores'
const dispatch = createEventDispatcher()
let video: HTMLVideoElement | null = null
$: if (video != null) {
video.srcObject = $recording?.stream ?? null
}
async function handleStop (): Promise<void> {
await stopRecording()
dispatch('close')
}
</script>
<div class="antiPopup antiPopup-withHeader thinStyle">
<div class="ap-space" />
<div class="ap-header">
<div class="ap-space x2" />
<div class="preview">
<!-- svelte-ignore a11y-media-has-caption -->
<video bind:this={video} width="100%" height="100%" autoplay muted />
</div>
</div>
<div class="ap-space" />
<div class="ap-scroll">
<div class="ap-box">
<div class="p-2 flex-row-center justify-end flex-grow">
<Button
label={plugin.string.Stop}
kind={'negative'}
icon={IconStop}
iconProps={{ size: 'large' }}
padding={'0 .5rem 0 .25rem'}
on:click={handleStop}
/>
</div>
<div class="ap-space" />
</div>
</div>
</div>
<style lang="scss">
.icon {
padding: 0.25rem;
}
.preview {
border-radius: 0.25rem;
border: 1px solid var(--theme-divider-color);
min-width: 15rem;
max-width: 20rem;
}
video {
border-radius: inherit;
}
</style>
@@ -0,0 +1,14 @@
<script lang="ts">
import { pushRootBarComponent } from '@hcengineering/ui'
import { onMount } from 'svelte'
import recorder from '../plugin'
let parentElement: HTMLDivElement
onMount(() => {
pushRootBarComponent('right', recorder.component.RecorderExt, 1300)
})
</script>
<div bind:this={parentElement} class="hidden"></div>
@@ -18,8 +18,10 @@
export let fill: string = 'currentColor'
</script>
<svg width="32" height="32" viewBox="0 0 32 32" class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg">
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 10.0002C4 8.34339 5.34315 7.00024 7 7.00024H19C20.6569 7.00024 22 8.34339 22 10.0002V22.0002C22 23.6571 20.6569 25.0002 19 25.0002H7C5.34315 25.0002 4 23.6571 4 22.0002V10.0002ZM7 5.00024C4.23858 5.00024 2 7.23882 2 10.0002V22.0002C2 24.7617 4.23858 27.0002 7 27.0002H19C21.7614 27.0002 24 24.7617 24 22.0002V20.9991L27.5997 23.7002C28.5885 24.4422 30 23.7366 30 22.5004V9.51017C30 8.27509 28.5908 7.56925 27.6017 8.30888L24 11.0021V10.0002C24 7.23882 21.7614 5.00024 19 5.00024H7ZM24 13.4994L28 10.5084V21.5001L24 18.4987V13.4994Z"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z"
/>
</svg>
@@ -1,25 +0,0 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg width="32" height="32" viewBox="0 0 32 32" class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg">
<path
d="M17 29C17 29.5523 16.5523 30 16 30C15.4477 30 15 29.5523 15 29V23.9807C11.6703 23.8517 8.63508 23.0825 6.33943 21.8662C3.86041 20.5527 2 18.5666 2 16.1121C2 14.3994 2.91858 12.9036 4.29003 11.7304C5.66135 10.5573 7.55607 9.63747 9.73381 9.03609C10.2662 8.88908 10.8169 9.20146 10.9639 9.73382C11.1109 10.2662 10.7985 10.8169 10.2662 10.9639C8.28415 11.5113 6.67887 12.3188 5.59014 13.2502C4.50153 14.1814 4 15.1673 4 16.1121C4 17.4616 5.04974 18.9195 7.2758 20.0989C9.24478 21.1422 11.948 21.8509 15 21.9791L15 3C15 2.44771 15.4477 2 16 2C16.5523 2 17 2.44772 17 3L17 21.9791C20.052 21.8509 22.7552 21.1422 24.7242 20.0989C26.9503 18.9195 28 17.4616 28 16.1121C28 15.1673 27.4985 14.1814 26.4099 13.2502C25.5635 12.5261 24.405 11.877 23 11.3667V15H21V9.00001H29V11H26.7411C27.0872 11.231 27.411 11.4747 27.71 11.7304C29.0814 12.9036 30 14.3994 30 16.1121C30 18.5666 28.1396 20.5527 25.6606 21.8662C23.3649 23.0825 20.3297 23.8517 17 23.9807V29Z"
/>
</svg>
@@ -0,0 +1,31 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg {fill} width="22" height="10" viewBox="0 0 22 10" xmlns="http://www.w3.org/2000/svg">
<path
d="M18.4162 9.33404C17.8642 9.33404 17.3642 9.24604 16.9162 9.07004C16.4762 8.88604 16.0962 8.61404 15.7762 8.25404C15.4642 7.89404 15.2242 7.45004 15.0562 6.92204C14.8882 6.38604 14.8042 5.76604 14.8042 5.06204C14.8042 4.35804 14.8882 3.73404 15.0562 3.19004C15.2242 2.63804 15.4642 2.17804 15.7762 1.81004C16.0962 1.43404 16.4762 1.15004 16.9162 0.958044C17.3642 0.766044 17.8642 0.670044 18.4162 0.670044C19.1682 0.670044 19.7922 0.830044 20.2882 1.15004C20.7842 1.47004 21.1802 1.95404 21.4762 2.60204L20.1202 3.32204C20.0002 2.94604 19.8042 2.64604 19.5322 2.42204C19.2602 2.19004 18.8882 2.07404 18.4162 2.07404C17.8242 2.07404 17.3522 2.27404 17.0002 2.67404C16.6562 3.07404 16.4842 3.63404 16.4842 4.35404V5.67404C16.4842 6.40204 16.6562 6.96204 17.0002 7.35404C17.3522 7.73804 17.8242 7.93004 18.4162 7.93004C18.8882 7.93004 19.2722 7.80204 19.5682 7.54604C19.8722 7.29004 20.0922 6.97404 20.2282 6.59804L21.5122 7.35404C21.2082 7.97804 20.8042 8.46604 20.3002 8.81804C19.7962 9.16204 19.1682 9.33404 18.4162 9.33404Z"
/>
<path
d="M7.96875 9.19009V0.814087H13.4888V2.21809H9.55275V4.24609H13.0328V5.65009H9.55275V7.78609H13.4888V9.19009H7.96875Z"
/>
<path
d="M1.584 9.19009H0V0.814087H3.78C4.164 0.814087 4.508 0.874087 4.812 0.994087C5.116 1.11409 5.372 1.29009 5.58 1.52209C5.796 1.74609 5.96 2.01809 6.072 2.33809C6.192 2.65809 6.252 3.01409 6.252 3.40609C6.252 3.98209 6.12 4.47809 5.856 4.89409C5.6 5.31009 5.212 5.60609 4.692 5.78209L6.408 9.19009H4.644L3.084 5.96209H1.584V9.19009ZM3.624 4.63009C3.928 4.63009 4.168 4.55009 4.344 4.39009C4.52 4.22209 4.608 3.98609 4.608 3.68209V3.13009C4.608 2.82609 4.52 2.59409 4.344 2.43409C4.168 2.27409 3.928 2.19409 3.624 2.19409H1.584V4.63009H3.624Z"
/>
</svg>
@@ -0,0 +1,25 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.8332 7.99996C10.8332 6.43515 9.56464 5.16663 7.99984 5.16663C6.43503 5.16663 5.1665 6.43515 5.1665 7.99996C5.1665 9.56477 6.43503 10.8333 7.99984 10.8333C9.56464 10.8333 10.8332 9.56477 10.8332 7.99996ZM11.8332 7.99996C11.8332 10.1171 10.1169 11.8333 7.99984 11.8333C5.88275 11.8333 4.1665 10.1171 4.1665 7.99996C4.1665 5.88287 5.88275 4.16663 7.99984 4.16663C10.1169 4.16663 11.8332 5.88287 11.8332 7.99996Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79086 10.2091 4 8 4C5.79086 4 4 5.79086 4 8C4 10.2091 5.79086 12 8 12Z"
/>
</svg>