825160ee46
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>
112 lines
4.3 KiB
TypeScript
112 lines
4.3 KiB
TypeScript
import { supabase } from './supabase';
|
||
|
||
const BUCKET = 'profile-banners';
|
||
// 3:1 hero crop. 1500x500 hits the sweet spot between crisp on a wide
|
||
// settings preview and a payload that stays well under the 8 MB pre-encode
|
||
// budget we accept from the user (post-WebP that's typically ~150–300 KB).
|
||
const TARGET_WIDTH = 1500;
|
||
const TARGET_HEIGHT = 500;
|
||
const QUALITY = 0.82;
|
||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||
|
||
// Resizes the source image to a centred 3:1 crop at TARGET_WIDTH x TARGET_HEIGHT
|
||
// and re-encodes as WebP (JPEG fallback if WebP encode unsupported).
|
||
async function resizeToBanner(file: File): Promise<Blob> {
|
||
const url = URL.createObjectURL(file);
|
||
try {
|
||
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 = url;
|
||
});
|
||
|
||
const srcRatio = img.naturalWidth / img.naturalHeight;
|
||
const targetRatio = TARGET_WIDTH / TARGET_HEIGHT;
|
||
let sx = 0;
|
||
let sy = 0;
|
||
let sw = img.naturalWidth;
|
||
let sh = img.naturalHeight;
|
||
if (srcRatio > targetRatio) {
|
||
// Source is wider than 3:1 — crop horizontally, keep full height.
|
||
sw = Math.round(img.naturalHeight * targetRatio);
|
||
sx = Math.round((img.naturalWidth - sw) / 2);
|
||
} else if (srcRatio < targetRatio) {
|
||
// Source is taller than 3:1 — crop vertically, keep full width.
|
||
sh = Math.round(img.naturalWidth / targetRatio);
|
||
sy = Math.round((img.naturalHeight - sh) / 2);
|
||
}
|
||
|
||
// Don't upscale — if the source crop is smaller than the target, render
|
||
// at the source crop size so we don't waste bytes on synthetic detail.
|
||
const outWidth = Math.min(TARGET_WIDTH, sw);
|
||
const outHeight = Math.min(TARGET_HEIGHT, sh);
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = outWidth;
|
||
canvas.height = outHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('canvas context unavailable');
|
||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outWidth, outHeight);
|
||
|
||
const blob = await new Promise<Blob | null>((resolve) =>
|
||
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
||
);
|
||
if (blob) return blob;
|
||
|
||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
|
||
);
|
||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||
return jpeg;
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
export async function uploadBanner(userId: string, file: File): Promise<string> {
|
||
if (!file.type.startsWith('image/')) {
|
||
throw new Error('only image files are accepted');
|
||
}
|
||
if (file.size > MAX_INPUT_BYTES) {
|
||
throw new Error('image must be 8 MB or smaller');
|
||
}
|
||
const blob = await resizeToBanner(file);
|
||
return uploadBannerBlob(userId, blob);
|
||
}
|
||
|
||
// Upload an already-cropped Blob (e.g. from ImageCropDialog). Skips the
|
||
// legacy center-crop path so the user-chosen framing is preserved.
|
||
export async function uploadBannerBlob(userId: string, blob: Blob): Promise<string> {
|
||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||
// Random filename so old uploads don't get overwritten before we update
|
||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||
// also forces clients to fetch the new image.
|
||
const name =
|
||
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
|
||
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
|
||
contentType: blob.type,
|
||
cacheControl: '604800',
|
||
upsert: false,
|
||
});
|
||
if (upErr) throw upErr;
|
||
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
|
||
return pub.publicUrl;
|
||
}
|
||
|
||
export const BANNER_TARGET_WIDTH = TARGET_WIDTH;
|
||
export const BANNER_TARGET_HEIGHT = TARGET_HEIGHT;
|
||
export const BANNER_MAX_INPUT_BYTES = MAX_INPUT_BYTES;
|
||
|
||
export async function deleteBannerObject(publicUrl: string): Promise<void> {
|
||
// Public URLs look like
|
||
// https://<host>/storage/v1/object/public/profile-banners/<path>
|
||
// Extract <path> and remove.
|
||
const marker = '/object/public/' + BUCKET + '/';
|
||
const idx = publicUrl.indexOf(marker);
|
||
if (idx === -1) return;
|
||
const path = publicUrl.slice(idx + marker.length);
|
||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||
if (error) throw error;
|
||
}
|