feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Cropper, { type Area } from 'react-easy-crop';
|
||||
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface Props {
|
||||
/** When true, the dialog is mounted. Always pair with a `key` change on
|
||||
* the source file so a fresh open re-runs the loader. */
|
||||
open: boolean;
|
||||
/** Source image. Object URL is created/revoked internally. */
|
||||
file: File | null;
|
||||
/** width:height ratio of the crop box. 1 for avatar, 3 for banner. */
|
||||
aspect: number;
|
||||
/** Output image dimensions in px. The crop area is scaled to this. */
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
title: string;
|
||||
/** Called with the encoded blob when the user confirms. The dialog stays
|
||||
* open until the parent flips `open` back off (typically after upload
|
||||
* completes) — gives the parent a chance to show errors inline. */
|
||||
onConfirm: (blob: Blob) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ENCODE_QUALITY = 0.85;
|
||||
|
||||
// Crop dialog used by both avatar (1:1) and banner (3:1) flows. Wraps
|
||||
// react-easy-crop in our standard modal chrome and runs the canvas crop
|
||||
// inline on confirm so the upload helpers receive a ready-to-store Blob.
|
||||
export function ImageCropDialog({
|
||||
open,
|
||||
file,
|
||||
aspect,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
title,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [areaPixels, setAreaPixels] = useState<Area | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resolve the file into an object URL exactly once per file. Revoked on
|
||||
// unmount or when the file changes so a long crop session doesn't leak.
|
||||
const objectUrl = useMemo(() => {
|
||||
if (!file) return null;
|
||||
return URL.createObjectURL(file);
|
||||
}, [file]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [objectUrl]);
|
||||
|
||||
// Reset transient UI state every time a new file is loaded so reopens
|
||||
// don't carry over the previous session's zoom/offset.
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setAreaPixels(null);
|
||||
setError(null);
|
||||
}, [file]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const submitting = useRef(false);
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (submitting.current || !objectUrl || !areaPixels) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await renderCrop({
|
||||
srcUrl: objectUrl,
|
||||
area: areaPixels,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
});
|
||||
onConfirm(blob);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'crop failed');
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [objectUrl, areaPixels, outputWidth, outputHeight, onConfirm]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={busy ? () => undefined : onClose} size="lg">
|
||||
<div className="flex flex-col gap-3 p-5">
|
||||
{/* Cropper canvas. Fixed height (320px) so the layout stays stable
|
||||
regardless of the source image dimensions; react-easy-crop fits
|
||||
the image into the box and the user pans/zooms inside. */}
|
||||
<div className="relative h-[320px] w-full overflow-hidden rounded-xl bg-black">
|
||||
{objectUrl && (
|
||||
<Cropper
|
||||
image={objectUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape={aspect === 1 ? 'round' : 'rect'}
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<label className="text-xs font-medium text-fg-muted" htmlFor="crop-zoom">
|
||||
Zoom
|
||||
</label>
|
||||
<input
|
||||
id="crop-zoom"
|
||||
type="range"
|
||||
min={1}
|
||||
max={4}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-rose-500 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={busy || !areaPixels}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
srcUrl: string;
|
||||
area: Area;
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
}
|
||||
|
||||
// Off-thread canvas would be nicer but the input is bounded (single image,
|
||||
// max ~12MP after react-easy-crop's clamp) so the main-thread cost is in
|
||||
// the tens of ms. Keeping it inline avoids the OffscreenCanvas + Worker
|
||||
// plumbing for a one-shot operation.
|
||||
async function renderCrop({
|
||||
srcUrl,
|
||||
area,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
}: RenderArgs): Promise<Blob> {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = srcUrl;
|
||||
});
|
||||
|
||||
// Don't upscale beyond the source crop. A 200x200 selection stays at
|
||||
// 200x200 instead of inflating to outputWidth — saves bytes and avoids
|
||||
// the soft look of canvas-resampled enlargement.
|
||||
const targetWidth = Math.min(outputWidth, Math.round(area.width));
|
||||
const targetHeight = Math.min(outputHeight, Math.round(area.height));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.drawImage(
|
||||
img,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
area.height,
|
||||
0,
|
||||
0,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', ENCODE_QUALITY),
|
||||
);
|
||||
if (blob) return blob;
|
||||
|
||||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', ENCODE_QUALITY),
|
||||
);
|
||||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||
return jpeg;
|
||||
}
|
||||
Reference in New Issue
Block a user