From 766c69be2389b3e09ff90b1157ed6299e9fe6db6 Mon Sep 17 00:00:00 2001 From: yorrd <kai@adornis.de> Date: Sat, 17 May 2025 19:48:14 +0000 Subject: [PATCH 1/2] feat: add screen capture --- src/client/ad-screen-recorder.ts | 127 +++++++++++++++++++++++++++++++ src/client/main-element.ts | 7 ++ src/translations.json | 3 +- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/client/ad-screen-recorder.ts diff --git a/src/client/ad-screen-recorder.ts b/src/client/ad-screen-recorder.ts new file mode 100644 index 0000000..df4db62 --- /dev/null +++ b/src/client/ad-screen-recorder.ts @@ -0,0 +1,127 @@ +import { getByID } from '@adornis/baseql/operations/mongo.js'; +import { XSnackbar } from '@adornis/chemistry/elements/components/x-snackbar.js'; +import { AdornisFile } from '@adornis/file-utils/db/files.js'; +import { stateful, useState } from '@adornis/functional-lit/stateful.js'; +import { html } from 'lit'; + +export const AdScreenRecorder = stateful(() => { + const [recording, setRecording] = useState(false); + const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null); + const [videoUrl, setVideoUrl] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [shareUrl, setShareUrl] = useState<string | null>(null); + const [screenStream, setScreenStream] = useState<MediaStream | null>(null); + + const stopScreenStream = (stream: MediaStream | null) => { + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setScreenStream(null); + } + }; + + const startRecording = async () => { + setError(null); + setVideoUrl(null); + setShareUrl(null); + try { + const screen = await (navigator.mediaDevices as any).getDisplayMedia({ video: true, audio: true }); + setScreenStream(screen); + let mic: MediaStream | null = null; + try { + mic = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch {} + + let mixedAudio: MediaStream | null = null; + if (screen.getAudioTracks().length > 0 || (mic && mic.getAudioTracks().length > 0)) { + const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const dest = ctx.createMediaStreamDestination(); + if (screen.getAudioTracks().length > 0) { + ctx.createMediaStreamSource(new MediaStream(screen.getAudioTracks())).connect(dest); + } + if (mic && mic.getAudioTracks().length > 0) { + ctx.createMediaStreamSource(new MediaStream(mic.getAudioTracks())).connect(dest); + } + mixedAudio = dest.stream; + } + + const tracks = [ + ...screen.getVideoTracks(), + ...(mixedAudio ? mixedAudio.getAudioTracks() : []) + ]; + const combined = new MediaStream(tracks); + let chunks: Blob[] = []; + const recorder = new MediaRecorder(combined, { mimeType: 'video/webm' }); + recorder.ondataavailable = (e: BlobEvent) => { + if (e.data.size > 0) chunks.push(e.data); + }; + recorder.onstop = async () => { + setRecording(false); + stopScreenStream(screenStream); + const blob = new Blob(chunks, { type: 'video/webm' }); + setVideoUrl(URL.createObjectURL(blob)); + if (!blob || blob.size === 0) { + setError('Recording failed: Blob is empty or invalid.'); + return; + } + try { + const fileToUpload = new File([blob], 'screen-recording.webm', { type: blob.type || 'video/webm' }); + const fileID = await AdornisFile.upload(fileToUpload); + const file = await getByID(AdornisFile, fileID)(AdornisFile.allFields); + setShareUrl(file?.getServeLink() ?? ''); + } catch (err: any) { + setError('Error generating share link: ' + (err?.message || err)); + } + }; + setMediaRecorder(recorder); + recorder.start(); + setRecording(true); + } catch (err: any) { + setError('Failed to start screen recording: ' + err.message); + } + }; + + const stopRecording = async () => { + if (mediaRecorder && mediaRecorder instanceof MediaRecorder) { + mediaRecorder.stop(); + setMediaRecorder(null); + setRecording(false); + stopScreenStream(screenStream); + } + }; + + return html` + <div class="flex justify-center min-h-[75vh] w-full overflow-auto"> + <div class="card bg-base-100 w-full max-w-lg p-8 flex flex-col items-center gap-8"> + <div class="w-full flex flex-col items-center gap-2"> + <div class="text-3xl font-bold mb-2">Screen Recorder</div> + <div class="flex gap-4 w-full justify-center"> + <button class="btn btn-primary" ?disabled=${recording} @click=${startRecording}>Start Recording</button> + <button class="btn btn-secondary" ?disabled=${!recording} @click=${stopRecording}>Stop Recording</button> + </div> + </div> + ${error ? html`<div class="alert alert-error w-full text-center">${error}</div>` : null} + ${videoUrl + ? html` + <div class="flex flex-col items-center gap-3 w-full"> + <video src="${videoUrl}" controls class="rounded-box w-full max-w-xl"></video> + <div class="flex gap-2 w-full justify-center"> + <button + class="btn btn-accent flex items-center gap-2" + @click=${() => { + navigator.clipboard.writeText(shareUrl ?? ''); + XSnackbar.show('Link copied to clipboard'); + }} + > + Share Link + </button> + <a class="btn btn-outline" href="${shareUrl}" target="_blank" download="screen-recording.webm"> + Download + </a> + </div> + </div> + ` + : null} + </div> + </div> + `; +}); diff --git a/src/client/main-element.ts b/src/client/main-element.ts index 82dd249..0258493 100644 --- a/src/client/main-element.ts +++ b/src/client/main-element.ts @@ -35,6 +35,8 @@ import { html, nothing } from 'lit'; import { version } from '../../package.json'; import logo from '../assets/ndixadornis.png'; import { DashboardUser } from '../db/dashboard-user.js'; +import './ad-screen-recorder.js'; +import { AdScreenRecorder } from './ad-screen-recorder.js'; import './customers/ad-page-customer-list.js'; import './profile/ad-profile.js'; import './project-management/ad-project-management.js'; @@ -101,6 +103,11 @@ export const router = makeRouter({ render: () => html`<ad-user-management flex></ad-user-management>`, metaData: { icon: 'people' }, }, + 'screen-recorder': { + path: '/screen-recorder', + render: () => AdScreenRecorder(), + metaData: { icon: 'screen_share', label: 'Screen Recorder' }, + }, settlement: { path: '/settlement*', render: () => html`<ad-settlement flex style="width: 100%"></ad-settlement>`, diff --git a/src/translations.json b/src/translations.json index 7b53ab4..8c8bb0e 100644 --- a/src/translations.json +++ b/src/translations.json @@ -13,7 +13,8 @@ "activity": ["Aktivität"], "time": ["Zeiterfassung & Urlaub"], "customers": ["Kunden"], - "faktura": ["Faktura"] + "faktura": ["Faktura"], + "screen-recorder": ["Screen Recorder"] }, "field": { "username": ["Nutzername"], -- GitLab From 05d0817bec7e60c35aee7ac96cc69f18c207efd8 Mon Sep 17 00:00:00 2001 From: yorrd <kai@adornis.de> Date: Sat, 17 May 2025 19:49:28 +0000 Subject: [PATCH 2/2] fix: make screen recording available to everyone --- src/client/main-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/main-element.ts b/src/client/main-element.ts index 0258493..3383487 100644 --- a/src/client/main-element.ts +++ b/src/client/main-element.ts @@ -257,7 +257,7 @@ export const MainElement = () => { <x-writer-toolbar></x-writer-toolbar> `; - if (router.currentRoute.value?.route.name === 'knowledge-base') return content; + if (['knowledge-base', 'screen-recorder'].includes(router.currentRoute.value?.route.name)) return content; return authenticationGuard( stateful(() => content), -- GitLab