@@ -249,7 +428,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
{t("home.recents.title")}
- {showFilterSelect &&
}
+ {headerActions}
{isWidgetLoading && }
diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx
index 47d1cb3..7ad127c 100644
--- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx
+++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx
@@ -7,7 +7,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType, MouseEvent, ReactNode } from "react";
import { useParams } from "next/navigation";
-import { LiveAudioVisualizer } from "react-audio-visualize";
import useSWR from "swr";
import {
AudioLines,
@@ -51,9 +50,11 @@ import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
+import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { useIssues } from "@/hooks/store/use-issues";
import useDebounce from "@/hooks/use-debounce";
+import { LiveAudioVisualizer } from "@/lib/vendor/react-audio-visualize";
// services
import { ProjectService } from "@/services/project";
import { WorkspaceAIService } from "@/services/workspace-ai.service";
@@ -442,8 +443,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
barColor={accentColor}
fftSize={1024}
minDecibels={-92}
- maxDecibels={-18}
- smoothingTimeConstant={0.68}
+ maxDecibels={-30}
+ smoothingTimeConstant={0.52}
/>
@@ -452,8 +453,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
function VoiceTaskProcessingState() {
return (
-
-
+
+
);
}
diff --git a/plane-src/apps/web/core/lib/b-progress/AppProgressBar.tsx b/plane-src/apps/web/core/lib/b-progress/AppProgressBar.tsx
index c3e0f03..e02c31a 100644
--- a/plane-src/apps/web/core/lib/b-progress/AppProgressBar.tsx
+++ b/plane-src/apps/web/core/lib/b-progress/AppProgressBar.tsx
@@ -4,9 +4,10 @@
* See the LICENSE file for details.
*/
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router";
+import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
import "@bprogress/core/css";
/**
@@ -66,10 +67,11 @@ const PROGRESS_CONFIG: Readonly
= {
* }
* ```
*/
-export default function AppProgressBar(): null {
+export default function AppProgressBar() {
const navigation = useNavigation();
const timerRef = useRef | null>(null);
const startedRef = useRef(false);
+ const [isLoaderVisible, setIsLoaderVisible] = useState(false);
// Initialize BProgress once on mount
useEffect(() => {
@@ -118,6 +120,7 @@ export default function AppProgressBar(): null {
BProgress.done();
startedRef.current = false;
}
+ setIsLoaderVisible(false);
} else {
// Navigation in progress (loading or submitting)
// Only start if not already started and no timer pending
@@ -127,6 +130,7 @@ export default function AppProgressBar(): null {
BProgress.start();
startedRef.current = true;
}
+ setIsLoaderVisible(true);
timerRef.current = null;
}, PROGRESS_CONFIG.delay);
}
@@ -139,5 +143,11 @@ export default function AppProgressBar(): null {
};
}, [navigation.state]);
- return null;
+ if (!isLoaderVisible) return null;
+
+ return (
+
+
+
+ );
}
diff --git a/plane-src/apps/web/core/lib/vendor/react-audio-visualize/index.ts b/plane-src/apps/web/core/lib/vendor/react-audio-visualize/index.ts
new file mode 100644
index 0000000..6b76699
--- /dev/null
+++ b/plane-src/apps/web/core/lib/vendor/react-audio-visualize/index.ts
@@ -0,0 +1 @@
+export { LiveAudioVisualizer } from "./live-audio-visualizer";
diff --git a/plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx b/plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
new file mode 100644
index 0000000..d797941
--- /dev/null
+++ b/plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
@@ -0,0 +1,172 @@
+/**
+ * Localized audio visualizer based on the public API shape of `react-audio-visualize`.
+ * It stays in-repo so the Voice Tasker recording UI can keep the package behavior
+ * while preserving NODEDC styling and silent-state dots.
+ */
+
+import { useCallback, useEffect, useRef } from "react";
+
+type LiveAudioVisualizerProps = {
+ backgroundColor?: string;
+ barColor?: string;
+ barWidth?: number;
+ fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
+ gap?: number;
+ height?: number;
+ maxDecibels?: number;
+ mediaRecorder: MediaRecorder;
+ minDecibels?: number;
+ smoothingTimeConstant?: number;
+ width?: number;
+};
+
+const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
+ let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
+ const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
+ const voiceBand = data.slice(1, voiceBandBinCount);
+ let binWindow = Math.floor(voiceBand.length / barsCount);
+
+ if (barsCount > voiceBand.length) {
+ barsCount = voiceBand.length;
+ binWindow = 1;
+ }
+
+ return Array.from({ length: barsCount }, (_, index) => {
+ let peak = 0;
+ let sumSquares = 0;
+ let count = 0;
+
+ for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
+ const value = voiceBand[index * binWindow + offset] ?? 0;
+ peak = Math.max(peak, value);
+ sumSquares += value * value;
+ count++;
+ }
+
+ const rms = Math.sqrt(sumSquares / Math.max(1, count));
+ return Math.max(rms, peak * 0.72);
+ });
+};
+
+export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
+ const {
+ backgroundColor = "transparent",
+ barColor = "rgb(160, 198, 255)",
+ barWidth = 2,
+ fftSize = 1024,
+ gap = 1,
+ height = 100,
+ maxDecibels = -10,
+ mediaRecorder,
+ minDecibels = -90,
+ smoothingTimeConstant = 0.4,
+ width = 300,
+ } = props;
+
+ const animationFrameRef = useRef(null);
+ const canvasRef = useRef(null);
+ const latestHeightsRef = useRef([]);
+
+ const draw = useCallback(
+ (rawValues: number[]) => {
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext("2d");
+ if (!canvas || !context) return;
+
+ const pixelRatio = window.devicePixelRatio || 1;
+ const canvasWidth = Math.max(1, Math.floor(width));
+ const canvasHeight = Math.max(1, Math.floor(height));
+
+ if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
+ canvas.width = canvasWidth * pixelRatio;
+ canvas.height = canvasHeight * pixelRatio;
+ }
+
+ canvas.style.width = `${canvasWidth}px`;
+ canvas.style.height = `${canvasHeight}px`;
+
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+ context.clearRect(0, 0, canvasWidth, canvasHeight);
+
+ if (backgroundColor !== "transparent") {
+ context.fillStyle = backgroundColor;
+ context.fillRect(0, 0, canvasWidth, canvasHeight);
+ }
+
+ const previousHeights = latestHeightsRef.current;
+ const centerY = canvasHeight / 2;
+ const maxBarHeight = canvasHeight * 0.94;
+ const nextHeights = rawValues.map((value, index) => {
+ const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
+ const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
+ const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
+ const previousHeight = previousHeights[index] ?? barWidth;
+ const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
+
+ return previousHeight + (targetHeight - previousHeight) * smoothing;
+ });
+
+ latestHeightsRef.current = nextHeights;
+
+ context.fillStyle = barColor;
+ nextHeights.forEach((barHeight, index) => {
+ const x = index * (barWidth + gap);
+ const y = centerY - barHeight / 2;
+ const radius = barWidth / 2;
+
+ context.beginPath();
+ if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
+ else context.rect(x, y, barWidth, barHeight);
+ context.fill();
+ });
+ },
+ [backgroundColor, barColor, barWidth, gap, height, width]
+ );
+
+ useEffect(() => {
+ const stream = mediaRecorder.stream;
+ if (!stream) return;
+
+ const AudioContextClass =
+ window.AudioContext ||
+ (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!AudioContextClass) return;
+
+ const audioContext = new AudioContextClass();
+ const analyser = audioContext.createAnalyser();
+ const source = audioContext.createMediaStreamSource(stream);
+
+ analyser.fftSize = fftSize;
+ analyser.minDecibels = minDecibels;
+ analyser.maxDecibels = maxDecibels;
+ analyser.smoothingTimeConstant = smoothingTimeConstant;
+ const frequencyData = new Uint8Array(analyser.frequencyBinCount);
+ source.connect(analyser);
+ void audioContext.resume();
+
+ const renderFrame = () => {
+ if (mediaRecorder.state === "recording") {
+ analyser.getByteFrequencyData(frequencyData);
+ draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
+ animationFrameRef.current = window.requestAnimationFrame(renderFrame);
+ return;
+ }
+
+ draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
+ };
+
+ renderFrame();
+
+ return () => {
+ if (animationFrameRef.current) {
+ window.cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = null;
+ }
+ source.disconnect();
+ analyser.disconnect();
+ if (audioContext.state !== "closed") void audioContext.close();
+ };
+ }, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
+
+ return ;
+}
diff --git a/plane-src/apps/web/package.json b/plane-src/apps/web/package.json
index da07bec..fa42d14 100644
--- a/plane-src/apps/web/package.json
+++ b/plane-src/apps/web/package.json
@@ -55,7 +55,6 @@
"next-themes": "0.4.6",
"pdfjs-dist": "5.4.296",
"react": "catalog:",
- "react-audio-visualize": "1.2.0",
"react-color": "^2.19.3",
"react-dom": "catalog:",
"react-dropzone": "^14.2.3",
diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css
index 8c6e7b4..bbbe717 100644
--- a/plane-src/apps/web/styles/globals.css
+++ b/plane-src/apps/web/styles/globals.css
@@ -3937,15 +3937,23 @@
color: var(--text-color-primary) !important;
}
+ .nodedc-processing-loader,
.nodedc-voice-task-processing-loader {
+ --nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);
width: 5rem;
aspect-ratio: 1;
box-sizing: border-box;
position: relative;
display: block;
- color: rgb(var(--nodedc-accent-rgb));
+ color: rgb(var(--nodedc-processing-loader-rgb));
}
+ .nodedc-processing-loader-white {
+ --nodedc-processing-loader-rgb: 255 255 255;
+ }
+
+ .nodedc-processing-loader::before,
+ .nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::before,
.nodedc-voice-task-processing-loader::after {
content: "";
@@ -3954,13 +3962,15 @@
display: block;
}
+ .nodedc-processing-loader::before,
.nodedc-voice-task-processing-loader::before {
inset: 1.125rem;
border: 0.5rem solid currentColor;
border-radius: 0.625rem;
- box-shadow: 0 0 1.25rem rgba(var(--nodedc-accent-rgb), 0.18);
+ box-shadow: 0 0 1.25rem rgba(var(--nodedc-processing-loader-rgb), 0.18);
}
+ .nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::after {
width: 1rem;
aspect-ratio: 1;
@@ -3968,7 +3978,7 @@
left: 0;
border-radius: 9999px;
background: currentColor;
- box-shadow: 0 0 1.125rem rgba(var(--nodedc-accent-rgb), 0.28);
+ box-shadow: 0 0 1.125rem rgba(var(--nodedc-processing-loader-rgb), 0.28);
offset-anchor: center;
offset-path: path("M 22 22 H 58 V 58 H 22 V 22");
animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
@@ -3995,4 +4005,93 @@
offset-distance: 100%;
}
}
+
+ .nodedc-processing-loader-fluid {
+ width: 5rem;
+ aspect-ratio: 1;
+ padding: 0.625rem;
+ box-sizing: border-box;
+ display: grid;
+ background: transparent;
+ filter: blur(5px) contrast(15);
+ mix-blend-mode: screen;
+ }
+
+ .nodedc-processing-loader-fluid::before,
+ .nodedc-processing-loader-fluid::after {
+ content: "";
+ position: static;
+ grid-area: 1 / 1;
+ display: block;
+ width: auto;
+ height: auto;
+ margin: 0.3125rem;
+ border: 0;
+ border-radius: 9999px;
+ background: currentColor;
+ box-shadow: none;
+ offset-path: none;
+ -webkit-mask-size: 100% 20px, 100% 100%;
+ mask-size: 100% 20px, 100% 100%;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-composite: destination-out;
+ mask-composite: exclude;
+ }
+
+ .nodedc-processing-loader-fluid::before {
+ -webkit-mask-image:
+ linear-gradient(#000 0 0),
+ linear-gradient(#000 0 0);
+ mask-image:
+ linear-gradient(#000 0 0),
+ linear-gradient(#000 0 0);
+ animation: nodedc-processing-fluid-shape 2s infinite;
+ }
+
+ .nodedc-processing-loader-fluid::after {
+ -webkit-mask-image: linear-gradient(#000 0 0);
+ mask-image: linear-gradient(#000 0 0);
+ animation:
+ nodedc-processing-fluid-shape 2s infinite,
+ nodedc-processing-fluid-jitter 0.5s infinite cubic-bezier(0.5, 200, 0.5, -200);
+ }
+
+ @keyframes nodedc-processing-fluid-shape {
+ 0% {
+ -webkit-mask-position: 0 20%, 0 0;
+ mask-position: 0 20%, 0 0;
+ }
+
+ 20% {
+ -webkit-mask-position: 0 80%, 0 0;
+ mask-position: 0 80%, 0 0;
+ }
+
+ 40% {
+ -webkit-mask-position: 0 100%, 0 0;
+ mask-position: 0 100%, 0 0;
+ }
+
+ 60% {
+ -webkit-mask-position: 0 0%, 0 0;
+ mask-position: 0 0%, 0 0;
+ }
+
+ 80% {
+ -webkit-mask-position: 0 35%, 0 0;
+ mask-position: 0 35%, 0 0;
+ }
+
+ 100% {
+ -webkit-mask-position: 0 0, 0 0;
+ mask-position: 0 0, 0 0;
+ }
+ }
+
+ @keyframes nodedc-processing-fluid-jitter {
+ 100% {
+ transform: translate(0.1px);
+ }
+ }
}
diff --git a/plane-src/pnpm-lock.yaml b/plane-src/pnpm-lock.yaml
index c32ef7a..f7d136d 100644
--- a/plane-src/pnpm-lock.yaml
+++ b/plane-src/pnpm-lock.yaml
@@ -668,9 +668,6 @@ importers:
react:
specifier: 'catalog:'
version: 18.3.1
- react-audio-visualize:
- specifier: 1.2.0
- version: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-color:
specifier: ^2.19.3
version: 2.19.3(react@18.3.1)
@@ -7467,12 +7464,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
- react-audio-visualize@1.2.0:
- resolution: {integrity: sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==}
- peerDependencies:
- react: '>=16.2.0'
- react-dom: '>=16.2.0'
-
react-color@2.19.3:
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
peerDependencies:
@@ -15083,11 +15074,6 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
- react-audio-visualize@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
- dependencies:
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
-
react-color@2.19.3(react@18.3.1):
dependencies:
'@icons/material': 0.2.4(react@18.3.1)