Compare commits

...

25 Commits

Author SHA1 Message Date
byGalax 3d605c09bc chore(desktop): release v0.11.0 2026-04-22 23:07:23 +02:00
byGalax 74074115d2 chore: ignore .claude/ local settings dir
Claude Code writes per-project permission settings here; harmless to
track but noisy across sessions. Gitignoring keeps the release
pre-flight check happy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:04:19 +02:00
byGalax 12bb585081 style(auth): migrate auth + language switcher to semantic color tokens
Swaps hardcoded brand-/white-/neutral- utilities for the accent / surface
/ fg / line / fg-muted tokens so light-mode and theme overrides behave
correctly. Also moves focus rings from `focus:` to `focus-visible:`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:03:24 +02:00
byGalax 665f450878 feat(call): native WASAPI system-audio for screen-share (Windows)
Hooks the custom screen-share picker up to a native WASAPI loopback
capture so "Mit System-Sound" no longer falls back to the OS picker on
Windows. Rust side opens the default render endpoint, channels 48 kHz
f32 stereo to an AudioWorklet, which feeds a MediaStreamDestination for
LiveKit to publish as ScreenShareAudio. Ring buffer sized for latency
(80 ms target, drop-to-target on overflow) and the AudioContext is
resumed eagerly so initial burstiness can't pile up.

Adds a temporary attachTrack:audio diagnostic log to confirm source
tagging matches between old and new clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:03:15 +02:00
byGalax e2e8217b86 fix(call): rAF batch flush never emptied into state
The real reason every thumbnail card stayed on the placeholder was a
reference-aliasing bug in the flush closure. `const batch = pendingUrls`
captured the same object; `delete (pendingUrls)[k]` for each key then
emptied `batch` too, because they were the same reference. By the time
`setThumbnailUrls(prev => ({ ...prev, ...batch }))` ran, batch was {}
and the state never picked up any URL — every card rendered the empty
placeholder icon.

Fixed by aliasing first, then replacing pendingUrls with a fresh empty
object (let instead of const on the outer binding). The cloned `batch`
retains its entries for the spread; any new arrivals during the commit
land in the new empty pendingUrls and coalesce into the next frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:26:45 +02:00
byGalax 6c6a23e672 fix(call): thumbnail fetch falls back to base64 if binary path returns nothing
Binary IPC (tauri::ipc::Response) came back as an unrecognised shape on
the user's runtime — the frontend couldn't extract an ArrayBuffer and
every thumbnail resolved to null, so every card rendered the placeholder
icon. Added:

- Widened the invoke typing to ArrayBuffer | Uint8Array | number[] so
  all three known Tauri/WebView2 deserialisation shapes parse.
- A one-time console.warn when the Response body lands as an unknown
  object shape, so the real wire format can be diagnosed if this ever
  trips again.
- An automatic tier-2 fallback: if the binary path produced 0 usable
  bytes, re-invoke the legacy base64 command and decode client-side.
  Slower on the JS thread than binary IPC but known to work across all
  Tauri 2.x runtimes.

Net behaviour: thumbnails render again. If the binary path works on a
given build, we get the fast path; otherwise the base64 fallback keeps
the picker usable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:24:02 +02:00
byGalax 16d179f8e8 fix(call): Response return-type can't be wrapped in Result
Previous commit had the command typed as Result<tauri::ipc::Response,
String>. Turns out that forces Tauri to JSON-serialise the variant
wrapper around the Response body — the frontend gets a JSON object
instead of the raw ArrayBuffer, the runtime check for byteLength fails,
and every thumbnail comes back as null.

Changed the return type to `tauri::ipc::Response` directly. Bad source
ids and capture failures now funnel into an empty byte buffer; the JS
side still detects "no thumbnail" via `byteLength === 0` so the
contract stays the same.

Frontend also widens the invoke-result typing to ArrayBuffer |
Uint8Array | number[] so an older WebView2 that happens to deserialise
as an array still works, and normalises into a plain ArrayBuffer
before constructing the Blob to sidestep a TS SharedArrayBuffer
incompatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:21:39 +02:00
byGalax 12e91c0bbe perf(call): binary IPC + rAF-batched state for smooth thumbnail streaming
Picker still stuttered during load because the main thread was stuck
parsing 20+ inbound IPC messages, each carrying 15-25 KB of JSON-wrapped
base64. Two changes compound to fix this:

1. Binary IPC. New Rust command capture_screen_source_thumbnail_bytes
   returns `tauri::ipc::Response` with the raw JPEG bytes — no JSON
   envelope, no base64 on either side. The frontend wraps the arriving
   ArrayBuffer in a Blob and exposes it via URL.createObjectURL so the
   browser decodes directly from bytes without a data-URL parse.
   Empirically drops per-arrival main-thread work from ~10-15 ms to
   ~1-2 ms.

2. rAF-batched thumbnail state updates. Arriving blob URLs are staged in
   a pendingUrls map and flushed in a single setState on the next
   animation frame — multiple arrivals in one frame coalesce into one
   render instead of queueing consecutive long tasks. Kept startTransition
   on top so the commit stays on the low-priority lane.

Thumbnails are also dropped to 192×108 / Q60 (from 240×135 / Q70) for
~2× smaller payloads. Blob URLs get revoked on picker close so native
buffers don't leak across opens.

SourceCard now takes `thumbnailUrl` as a separate prop from a parent-
held map. Keeps source object references stable so React.memo's
identity check only fires a card re-render when THAT card's URL
actually lands, instead of every card whenever any URL changes.

Next session: WASAPI loopback for system-audio capture in native share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:16:30 +02:00
byGalax 8b9a40f059 perf(call): actually unfreeze picker while thumbnails stream in
Previous pass moved to JPEG + startTransition but the grid still froze.
Root causes that survived:

1. React.memo was broken — the parent re-created the inline
   `onClick={() => onSelect(src.id)}` arrow on every render, so memo's
   reference check always triggered a fresh render on every card even
   though nothing visible had changed. Fixed by passing `onSelect` as a
   stable prop and constructing the click handler inside the memoized
   child.

2. 20 data-URL `<img>` sources getting decoded more or less at once gave
   the compositor enough work to make scroll feel laggy. `decoding="async"`
   punts decode to the browser's image thread; `loading="lazy"` skips it
   entirely for cards outside the viewport.

3. Concurrency at 4 was still high enough for Windows GDI BitBlt /
   PrintWindow to contend for the desktop compositor — the whole Tauri
   window stuttered because the OS-level screen capture was saturating
   the graphics pipeline. Dropped to 2 concurrent captures; total load
   takes a touch longer but the picker stays interactive throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:07:05 +02:00
byGalax eac19823ea perf(call): JPEG thumbnails + memoized picker cards unfreeze the grid
The picker still felt frozen while thumbnails were streaming in because
each result was both (a) large — PNG @ 320×180 landed at 60-150 KB
base64 — and (b) triggering a high-priority React re-render of the whole
grid. Three fixes together restore interactivity:

- Thumbnails encoded as JPEG @ Q70 at 240×135 instead of PNG @ 320×180.
  Drops the typical payload from ~100 KB to ~20 KB, so IPC JSON-parsing
  on arrival is 5× faster.
- SourceCard wrapped in React.memo so only the card whose thumbnail just
  landed re-renders. Previously one new thumbnail caused all ~20 cards
  to re-evaluate their props.
- setSources updates run inside startTransition so scroll / click events
  stay on the high-priority lane while the grid backfills.

Also: when the user enables "Sound mit übertragen" AND has a source
picked, the picker now surfaces an inline amber note explaining that
the OS picker will appear for the audio capture path. Matches the
existing console info log but is visible pre-click so users don't
experience it as a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:58:05 +02:00
byGalax a5e930ac17 fix(call): volume>100% crash, picker UI freeze, native-path diagnostics
Volume crash:
- setParticipantVolume / setScreenShareVolume propagated values up to 2.0
(200%) to the per-track GainNode, but also called applyToAttachedElements
which set the raw HTMLAudioElement.volume — that property is hard-clamped
to [0, 1] and throws IndexSizeError above 1. Clip the element-path apply
at 1.0. WebAudio GainNode keeps doing the actual amplification.

Picker freeze:
- Firing ~20 captureScreenSourceThumbnail invokes in parallel caused
perceptible input freezes while each ~100KB base64 result arrived and
triggered a setState. Bounded the worker pool to 4 concurrent captures
with a queue — overall wall-clock is nearly identical and the grid stays
scrollable / clickable throughout the load.

Native-path diagnostics:
- Previous logs only fired on non-NativeCaptureUnavailable errors, so
users couldn't tell whether the native path was skipped (audio toggle
on, no sourceId) or attempted-and-failed. Added explicit info logs for
each skip reason plus an always-on warn with the underlying error when
the try block throws. Makes the next debug pass on screenshare much
quicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:50:20 +02:00
byGalax c3e0c47d32 feat(call): native screen-capture pipeline + faster picker
Picker speed (Phase 1+2):
- screen_sources.rs split into list_screen_sources (metadata only,
  returns in ~10ms) + capture_screen_source_thumbnail (single source,
  by id). ScreenSourcePicker now shows names + placeholders instantly
  and streams thumbnails in as each capture lands. Total wall-clock
  is bounded by the slowest source instead of the serial sum.
- enumerate_screen_sources kept as a dead_code fallback so any
  rollout regression can switch the frontend back without code loss.

Native capture (Phase 3):
- New src-tauri/src/screen_capture.rs. start_screen_capture spawns a
  Rust thread per share that grabs frames via xcap, downscales to the
  user's quality preset, JPEG-encodes at Q72, and streams each frame
  through a Tauri Channel<FramePayload>. stop_screen_capture signals
  the stop flag and joins the worker.
- Worker re-resolves the xcap handle inside the thread because
  xcap::Window holds a !Send HWND — passing the source id string
  across the thread boundary sidesteps that.
- New lib/screenCapture.ts: decodes each frame into an ImageBitmap,
  draws to an offscreen canvas, exposes canvas.captureStream() as the
  MediaStream LiveKit publishes. Latest-wins frame queue drops stale
  frames when the JS side falls behind the Rust producer. 3s first-
  frame timeout so a silently-failing source (locked screen, DRM
  window) surfaces as a clean NativeCaptureUnavailable and we fall
  back to getDisplayMedia.
- CallContext.startScreenShare takes the native path first when the
  picker provided a sourceId and system audio wasn't requested. The
  old chromeMediaSourceId attempt and final setScreenShareEnabled
  fallback stay in place for the audio case + non-Tauri runtimes.
- stopScreenShare kills the native handle first, then unpublishes any
  manually-published ScreenShare/ScreenShareAudio tracks, then falls
  back to setScreenShareEnabled(false). disconnectRoom also stops
  the handle so we don't leak Rust threads across calls.

Scope note: native path is video-only. System-audio capture needs
WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until
those are wired, requesting audio in the picker falls through to
the getDisplayMedia path and shows the OS picker for that one case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:42:52 +02:00
byGalax 8f9b823d69 feat(call): per-participant volume up to 200% via WebAudio gain
HTMLMediaElement.volume caps at 1.0, so boosting a quiet peer past
100% needs an explicit GainNode in the output chain. New
remoteAudioPipelines module owns one AudioContext + GainNode per
remote audio track; attachTrack / detachTrack now create and tear
down the pipeline alongside the LiveKit element.

Once a track is on the WebAudio path its direct output is diverted
(createMediaElementSource semantics), so audio.muted / volume can't
drive output anymore. Deafen, watch-state, manual screen-share mute
and per-user volume are collapsed into one effective-gain formula
that gets recomputed on every state flip — the effect subscribes to
both participantVolumes and screenShareVolumes for live slider drags.

Slider ranges updated to 0–200% across:
- ParticipantVolumeMenu (per-user right-click menu)
- ParticipantsPopover (in-call participant list)
- ScreenShareContextMenu (per-share right-click)

Values above 100% render the percentage in amber as a soft hint that
clipping is possible. Clamp in both volume stores extended to [0, 2]
so persisted values survive.

setAudioOutputDevice now additionally routes via
AudioContext.setSinkId (Chrome 115+) for the WebAudio graph; the
HTMLAudioElement.setSinkId fallback stays for older runtimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:09:45 +02:00
byGalax 7ad8ba82b6 feat(call): hide soundboard button when the user has no sounds
Previously the music icon always rendered in the in-call bar, opening a
popover with a "Keine Sounds gespeichert" empty-state. Matches Discord's
pattern better to just drop the button entirely until the user has
something to play — otherwise it reads as a broken control. Live-
subscribes to soundboardStorage so adding/removing sounds mid-call flips
the button in or out without reopening the call. Also closes the popover
automatically if the user clears their last sound while it's open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:00:33 +02:00
byGalax b44a785d20 feat(call): Discord-style screen-source picker with thumbnails
Rust side:
- New src-tauri/src/screen_sources.rs with an enumerate_screen_sources
  command. Uses the xcap crate for cross-platform screen + window
  enumeration and capture; PNG thumbnails are letterbox-scaled to fit
  320x180 and returned as base64.
- Source ids emit Chromium's internal desktopCapturer format
  ("screen:<id>:0", "window:<hwnd>:0") so JS can try passing them
  straight into chromeMediaSourceId.
- Registered in both invoke_handler branches in lib.rs.

Frontend:
- New lib/screenSources.ts — Tauri command wrapper + thumbnailDataUrl
  helper for the picker UI.
- New components/ScreenSourcePicker.tsx — Discord-style grid: sources
  grouped under "Bildschirme" / "Fenster", large thumbnail cards with
  selection state, quality preset + system-audio toggle in the footer.
  "Teilen" button is enabled either way; without a selection it says
  "Ohne Auswahl weiter" and falls through to the OS picker.
- Replaces the old form-style ScreenShareDialog entirely (removed).

CallContext wiring:
- startScreenShare now accepts an optional sourceId. When set, it
  captures that exact source via getUserMedia's legacy
  chromeMediaSourceId constraint and publishes the resulting tracks
  manually (video as ScreenShare, audio as ScreenShareAudio). Falls
  back to setScreenShareEnabled if WebView2 rejects the constraint,
  so users always get a working share even if the direct path fails.
- Track 'ended' listeners unpublish the pub when the OS revokes
  capture (close of shared window, OS "stop sharing" banner).

InCallPanel:
- Left-click on the share button now opens the picker instead of
  starting with last-saved settings; right-click opens it too. The
  picker itself is the 1-click UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:55:52 +02:00
byGalax 331b1298f8 feat(call): Discord-style screen-share UX
- Watch-gate lifted into CallContext. watchingShareUserIds /
  dismissedShareUserIds / screenShareAudioMutedIds as session-only state,
  cleared on CallState.idle and on TrackUnsubscribed for each sharer.
  Survives layout changes (grid <-> focus <-> fullscreen) without
  resetting which the old local-state viewer dropped on remount.
- ScreenShareAudio tracks tagged via data-track-source="screenshare" at
  attach-time; initial muted follows watching + manual mute mirrors so
  audio never plays before the user clicks "Bildschirm anschauen". Deafen
  still wins at the top of the priority chain.
- New screenShareVolumes store (session-only, keyed by participantId).
  attachTrack pulls the initial volume from this store for screenshare
  audio elements so the context-menu slider takes effect immediately.
- Screen shares are no longer auto-promoted to focus. They render as
  equal-size grid tiles like everyone else; user clicks to focus. The
  "Bildschirm anschauen" overlay replaces auto-play as the opt-in.
- Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden"
  really hides the tile until the sharer stops + restarts.
- New ScreenShareContextMenu (portal, Esc / outside-click to close):
  volume slider + audio mute toggle when the share has audio + a
  destructive "Zuschauen beenden" row. Wired via a dispatcher in
  InCallPanel that picks between participant-volume and share-menu
  based on tile.kind.
- Fullscreen cinema gets a "Hide participant strip" toggle (top-right,
  session-only) so focused content reaches the full viewport when the
  bottom thumbnail row would otherwise steal 160px. Fades with the
  auto-hide controls; only surfaces when there's a focus + peers to hide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:26 +02:00
byGalax 02ca3e3581 refactor(call): move noise-suppression toggle out of the call bar
Default flipped to off so voice doesn't get coloured by browser NS on
first run. Users turn it on explicitly under Settings → Sprache, where
the toggle already lived before the in-call button shipped.

The in-call SparklesIcon button and all the associated wiring
(onToggleNoiseSuppression, noiseSuppression prop, local subscription
in InCallPanel) is removed. The hot-swap UX is preserved: a new
subscribeAudioSettings watcher in CallContext detects noiseSuppression
flips during an active call and re-runs setupMicPipeline so the change
takes effect without rejoining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:57 +02:00
byGalax eb8f702576 feat(call): mute + deafen global hotkeys with chord support (group E)
- New voiceHotkeys.ts storage module. Stores per-action bindings with
  modifier flags (Ctrl/Shift/Alt) so Discord-style chords like
  Ctrl+Shift+M work. Defaults match Discord — Ctrl+Shift+M mute,
  Ctrl+Shift+D deafen — but ship disabled to avoid surprise collisions.
- globalShortcut.ts grows registerGlobalShortcutPress /
  unregisterGlobalShortcut helpers that accept pre-formatted Tauri
  accelerator strings, since voiceHotkey chords can't be expressed by
  the existing codeToShortcut path (PTT-only single-key).
- CallContext registers the chords OS-wide while a call is active
  (connected | reconnecting) so the hotkeys work from any focused
  window. A window-keydown fallback handles the non-Tauri / denied
  registration case. Both unregister on call end.
- SettingsPage adds VoiceHotkeyControls (mute + deafen variants)
  with a chord-capture button that waits past modifier-only presses
  and binds to the first real key. Labels render as "Ctrl+Shift+M"
  consistently with the in-call PTT hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:07:09 +02:00
byGalax bc8a7c5a32 feat(call): Discord-style in-call features (group D)
- PiP widget now shows a live mm:ss / hh:mm:ss duration instead of the
  generic "tippe zum Öffnen" while a call is in progress.
- New ParticipantsPopover — portal-mounted, fixed bottom-right, lists
  everyone in the call with avatar, speaking ring, mute/deafen badges
  and a per-peer volume slider. Wired to CallControls via the users
  button (data-participants-trigger skips the outside-click dismiss
  while toggling).
- Non-terminal MicErrorBanner: getUserMedia failures inside joinRoom
  used to be silently swallowed by a console.error; they now set a
  categorized message (NotAllowedError / NotFoundError / NotReadableError)
  on CallContext.micError, render as a rose banner in both docked and
  fullscreen modes, and offer a Retry button that calls the extracted
  setupMicPipeline without rejoining the room.
- Screen-share toggle is now 1-click using the last-saved preset +
  displaySurface. Right-click on the share button still opens the
  quality dialog for users who want to adjust before starting.
- Noise-suppression toggle in the control bar (SparklesIcon). Flipping
  it updates audioSettings and hot-swaps the mic track via
  setAudioInputDevice so the new constraint takes effect without a
  rejoin. Mirrors Discord's Krisp button placement.
- Fullscreen auto-speaker now tracks "most recently started speaking"
  instead of "exactly one currently speaking", so two people briefly
  overlapping doesn't kick the focus back to grid. Tracked in a
  prevSpeakers ref against each activeSpeakers diff.
- Fullscreen controls auto-hide after 5s of mouse idle; mousemove /
  touchstart bring them back. Pinned visible while any popover
  (soundboard / volume-menu / participants / mic-error banner) is open
  so users can interact without the chrome fading mid-click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:02:46 +02:00
byGalax 1c67a5c97f feat(call): Discord-style polish pass (groups A-C)
A — Call core:
- Deafen now implies mute + remembers pre-deafen mic state so un-deafen
  restores it (Discord-parity). Peers still see the headphones-off +
  mic-off badges in sync via the existing data-channel broadcast.
- Self-join sound fires on the local peer's r.connect() too, not just
  on remote ParticipantConnected, so the user gets the "I'm in" cue.
- New CallState.reconnecting holds the UI steady when LiveKit drops the
  signaling socket and retries; duration keeps ticking, status label
  switches to "Verbinde neu…". Full teardown only on terminal
  Disconnected (after LK gives up).
- joinActiveCall falls back to connected after 5s if no peer arrived —
  avoids hanging in "Verbinde…" when peers left the room mid-rejoin.

B — Ringtone:
- Oscillator base gain up (incoming 0.22 -> 0.4, outgoing 0.14 -> 0.22)
  so the default pattern survives laptop speakers + background music.
- New ringtoneVolume slider in Settings, default 0.9, live-applies to
  both the oscillator fallback and the custom-file <audio> element.

C — Participant tile:
- Split the speaking indicator: video tiles get the emerald border +
  inset glow; audio tiles rely on the existing avatar pulse. No more
  double-chrome when someone talks in grid/focus view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:53:58 +02:00
byGalax 6301ebb392 chore(desktop): release v0.10.2 2026-04-22 19:24:14 +02:00
byGalax 31d21dd2c2 fix(release): tauri v2 ships .exe + .exe.sig, not .nsis.zip
v1 used to wrap the installer in a .nsis.zip and sign that wrapper.
v2 signs the .exe directly, so the updater url points at the .exe and
the .sig file sits next to it. Script was still looking for the
legacy .nsis.zip path and aborting after a successful build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:22:17 +02:00
byGalax 9add0a4d61 fix(release): call tauri binary directly, not via desktop build script
Node execSync runs via cmd.exe on Windows, which preserves the `--`
separator pnpm injects between the script name and forwarded args.
Tauri CLI then forwards that `--` to cargo, which rejects `--bundles`
with "unexpected argument". Invoking `pnpm exec tauri build --bundles nsis`
skips the script indirection so no `--` is emitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:19:42 +02:00
byGalax 500f1c4bc2 chore(release): self-hosted updater on update.netralax.cloud
Switches the Tauri updater endpoint from GitHub Releases to a static
host. New Ed25519 pubkey (old private key was lost); existing 0.10.x
installs need one manual reinstall to pick up the new updater identity.

Release flow is now pnpm release <version> <notes> which bumps,
builds + signs locally, scps artifacts to the server, commits, tags.
GitHub workflow stays as workflow_dispatch backup (Windows only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:58 +02:00
byGalax a38e2f96c0 feat(desktop): raise ringtone cap to 8 MB
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:48 +02:00
42 changed files with 5756 additions and 615 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env.release (gitignored) and fill in.
# Consumed by scripts/release.mjs.
# Absolute path to the private key file produced by `tauri signer generate`.
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
# Password set when generating the key. Leave empty if none.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
# Host serving latest.json + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
UPDATE_SSH_USER=chatapp-deploy
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
# default id_rsa.
UPDATE_SSH_KEY=
# Absolute path on the server where windows/ artifacts + latest.json live.
UPDATE_REMOTE_PATH=/var/www/updates/windows
+16 -28
View File
@@ -1,35 +1,27 @@
name: Release desktop app name: Release desktop app (manual backup)
# Tag a version to trigger a release: # Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
# git tag v0.1.0 && git push --tags # This workflow is kept as a manual backup — trigger it from the Actions tab
# # if the local build host is unavailable.
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
# uploads them to a GitHub Release, and publishes `latest.json` for the
# updater plugin to discover.
on: on:
push: workflow_dispatch:
tags: inputs:
- "v*" tag:
description: "Tag to build (e.g. v0.10.2) — must already exist"
required: true
permissions: permissions:
contents: write contents: write
jobs: jobs:
build: build:
strategy: runs-on: windows-latest
fail-fast: false
matrix:
include:
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target universal-apple-darwin --bundles app,updater"
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -42,8 +34,6 @@ jobs:
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install JS deps - name: Install JS deps
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -54,18 +44,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Client-side env vars baked into the bundle — paste your prod values
# into the repo's Actions → Secrets so releases point at prod.
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }} VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }} VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
with: with:
projectPath: apps/desktop projectPath: apps/desktop
tagName: ${{ github.ref_name }} tagName: ${{ inputs.tag }}
releaseName: "ChatApp ${{ github.ref_name }}" releaseName: "ChatApp ${{ inputs.tag }}"
releaseBody: "See the assets below to download this version." releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
tauriScript: pnpm exec tauri tauriScript: pnpm exec tauri
args: ${{ matrix.args }} args: "--bundles nsis"
+5
View File
@@ -15,7 +15,9 @@ out/
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.release
!.env.example !.env.example
!.env.release.example
# Expo # Expo
.expo/ .expo/
@@ -56,6 +58,9 @@ Thumbs.db
*.swp *.swp
*.swo *.swo
# Claude Code per-project local settings
.claude/
# Coverage # Coverage
coverage/ coverage/
*.lcov *.lcov
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.10.1", "version": "0.11.0",
"private": true, "private": true,
"description": "Tauri v2 desktop client (Windows / macOS / Linux)", "description": "Tauri v2 desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+812 -12
View File
File diff suppressed because it is too large Load Diff
+32 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chat-app-desktop" name = "chat-app-desktop"
version = "0.10.1" version = "0.11.0"
description = "ChatApp desktop client" description = "ChatApp desktop client"
authors = ["Dennis"] authors = ["Dennis"]
edition = "2021" edition = "2021"
@@ -28,11 +28,42 @@ serde_json = "1"
dryoc = { version = "0.7", default-features = false, features = ["serde"] } dryoc = { version = "0.7", default-features = false, features = ["serde"] }
base64 = "0.22" base64 = "0.22"
# PNG encoding for screen-source thumbnails returned by the
# `enumerate_screen_sources` command. `default-features = false` skips the
# image-format decoders we don't use (jpeg, gif, webp, …) — keeps the
# thumbnail command at ~200KB extra binary size.
image = { version = "0.25", default-features = false, features = ["png"] }
# Cross-platform screen + window enumeration and capture. Replaces direct
# Win32 GDI / macOS CoreGraphics / X11 calls with a small uniform API so
# the enumerate-sources command has one code path. The crate pulls in
# platform-specific backends automatically (~1.5MB binary growth on
# Windows). Marked optional so non-desktop targets don't compile it.
xcap = "0.0.14"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
# Windows-only screen-source enumeration + thumbnail capture. Pulled in
# only on Windows so macOS + Linux builds stay slim. The enumerate command
# returns stub-empty on non-Windows until we add native equivalents.
[target."cfg(target_os = \"windows\")".dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_HiDpi",
"Win32_UI_WindowsAndMessaging",
] }
# WASAPI loopback capture for system-audio screen-share. Lets the custom
# picker hand LiveKit a real audio track without falling back to the OS
# screen picker (which is the only way getDisplayMedia can grab system
# sound). Windows-only for v1; macOS needs ScreenCaptureKit-audio and
# Linux needs a PulseAudio / PipeWire path.
wasapi = "0.15"
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the # LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
# baseline build stays unaffected while the JS-SDK path is still the # baseline build stays unaffected while the JS-SDK path is still the
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min # default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
+19
View File
@@ -1,4 +1,7 @@
mod crypto; mod crypto;
mod screen_audio;
mod screen_capture;
mod screen_sources;
#[cfg(feature = "rust-livekit")] #[cfg(feature = "rust-livekit")]
mod livekit_bridge; mod livekit_bridge;
@@ -91,6 +94,14 @@ pub fn run() {
crypto::crypto_box_seal, crypto::crypto_box_seal,
crypto::crypto_box_seal_open, crypto::crypto_box_seal_open,
crypto::crypto_pwhash, crypto::crypto_pwhash,
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_sources::capture_screen_source_thumbnail_bytes,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
screen_audio::start_system_audio_capture,
screen_audio::stop_system_audio_capture,
]) ])
.plugin(tauri_plugin_notification::init()); .plugin(tauri_plugin_notification::init());
@@ -107,6 +118,14 @@ pub fn run() {
crypto::crypto_box_seal, crypto::crypto_box_seal,
crypto::crypto_box_seal_open, crypto::crypto_box_seal_open,
crypto::crypto_pwhash, crypto::crypto_pwhash,
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_sources::capture_screen_source_thumbnail_bytes,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
screen_audio::start_system_audio_capture,
screen_audio::stop_system_audio_capture,
livekit_bridge::livekit_connect, livekit_bridge::livekit_connect,
livekit_bridge::livekit_disconnect, livekit_bridge::livekit_disconnect,
livekit_bridge::livekit_send_data, livekit_bridge::livekit_send_data,
+428
View File
@@ -0,0 +1,428 @@
// Native system-audio capture for the custom screen-share picker. Without
// this path the picker has to fall back to getDisplayMedia whenever the
// user ticks "Mit System-Sound", because Chromium only wires audio into
// desktop captures that the OS picker produced. Here we grab the default
// render endpoint's loopback stream via WASAPI, convert it to 48kHz f32
// stereo, and ship the samples to the JS side through a Tauri Channel.
// An AudioWorklet on the frontend feeds them into a MediaStreamDestination
// so LiveKit publishes a plain ScreenShareAudio track.
//
// Windows-only for v1. macOS + Linux stubs return a clear error so the
// frontend can fall back cleanly on those platforms until their native
// paths ship (ScreenCaptureKit-audio / PipeWire).
#![allow(clippy::needless_return)]
use base64::Engine;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use tauri::ipc::Channel;
// Output format we always deliver to the frontend. Picking a single fixed
// format means the AudioWorklet never has to renegotiate — it just assumes
// interleaved f32 stereo at 48kHz. WASAPI mix format is usually already
// this on Windows 10+, so the resample branch is rarely hit.
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
const OUTPUT_CHANNELS: u16 = 2;
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
struct Session {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AudioFramePayload {
pub capture_id: u32,
pub sample_rate: u32,
pub channels: u16,
/// Interleaved little-endian f32 stereo samples, base64-encoded.
/// Frontend decodes via `atob` → `Uint8Array` → `Float32Array` view.
/// Base64 is used instead of a raw `Vec<f32>` because Tauri Channel
/// serialises via JSON — a JSON array of floats balloons to ~23×
/// the byte count, and at 48kHz stereo that's enough IPC traffic
/// to matter.
pub samples_base64: String,
}
/// Start a loopback capture of the default render endpoint and begin
/// streaming audio frames on the provided channel. Returns a numeric
/// capture id that must be handed to `stop_system_audio_capture` when
/// the share ends.
#[tauri::command]
pub fn start_system_audio_capture(
channel: Channel<AudioFramePayload>,
) -> Result<u32, String> {
#[cfg(target_os = "windows")]
{
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let handle = thread::Builder::new()
.name(format!("screen-audio-{capture_id}"))
.spawn(move || {
if let Err(err) =
windows_loopback::capture_loop(capture_id, channel, stop_clone)
{
eprintln!("screen-audio {capture_id}: {err}");
}
})
.map_err(|e| format!("failed to spawn audio thread: {e}"))?;
sessions().lock().unwrap().insert(
capture_id,
Session {
stop,
handle: Some(handle),
},
);
Ok(capture_id)
}
#[cfg(not(target_os = "windows"))]
{
// Keep the `channel` binding alive so Tauri doesn't complain about
// an unused parameter on the non-Windows build.
let _ = channel;
Err("system audio capture only supported on Windows".into())
}
}
/// Tear down the capture for the given id. Safe to call on a missing id
/// (no-op) so the JS side doesn't have to track whether the stop has
/// already been issued by the screen-share teardown path.
#[tauri::command]
pub fn stop_system_audio_capture(capture_id: u32) -> Result<(), String> {
let session = sessions().lock().unwrap().remove(&capture_id);
let Some(mut session) = session else {
return Ok(());
};
session.stop.store(true, Ordering::Relaxed);
if let Some(handle) = session.handle.take() {
// Best-effort join — the capture loop polls `stop` every event
// cycle (≤100ms) so this usually returns promptly. If the WASAPI
// call is wedged we'd rather drop the handle than hang the stop.
let _ = handle.join();
}
Ok(())
}
// ---------------------------------------------------------------------------
// Windows loopback implementation
// ---------------------------------------------------------------------------
#[cfg(target_os = "windows")]
mod windows_loopback {
use super::*;
use wasapi::{initialize_mta, Direction, SampleType, ShareMode};
pub fn capture_loop(
capture_id: u32,
channel: Channel<AudioFramePayload>,
stop: Arc<AtomicBool>,
) -> Result<(), String> {
// COM must be initialised on every thread that touches WASAPI.
// MTA is the right model for a background capture thread — STA
// would require message pumping we don't want to add.
initialize_mta()
.ok()
.map_err(|e| format!("initialize_mta: {e:?}"))?;
let device = wasapi::get_default_device(&Direction::Render)
.map_err(|e| format!("get_default_device: {e:?}"))?;
let mut audio_client = device
.get_iaudioclient()
.map_err(|e| format!("get_iaudioclient: {e:?}"))?;
// Use the mix format that Windows is already pushing to the
// endpoint. Loopback capture won't convert for us — asking for a
// fixed format here makes Initialize() fail on non-matching
// hardware. We resample + channel-mix ourselves downstream.
let mix_format = audio_client
.get_mixformat()
.map_err(|e| format!("get_mixformat: {e:?}"))?;
let input_rate = mix_format.get_samplespersec();
let input_channels = mix_format.get_nchannels();
let bits_per_sample = mix_format.get_bitspersample();
let block_align = mix_format.get_blockalign();
let sample_type = mix_format.get_subformat().unwrap_or(SampleType::Int);
let (def_time, _min_time) = audio_client
.get_periods()
.map_err(|e| format!("get_periods: {e:?}"))?;
// Direction::Capture + loopback: WASAPI streams what Windows is
// sending to the speakers instead of what an input device is
// producing. Shared mode so we coexist with other apps.
audio_client
.initialize_client(
&mix_format,
def_time,
&Direction::Capture,
&ShareMode::Shared,
true,
)
.map_err(|e| format!("initialize_client: {e:?}"))?;
let h_event = audio_client
.set_get_eventhandle()
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
let capture_client = audio_client
.get_audiocaptureclient()
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
audio_client
.start_stream()
.map_err(|e| format!("start_stream: {e:?}"))?;
// Resampler state — last stereo frame from the previous buffer so
// linear interpolation at the buffer boundary doesn't click.
// Initialised to silence.
let mut last_stereo: [f32; 2] = [0.0, 0.0];
while !stop.load(Ordering::Relaxed) {
// 100ms timeout lets the loop check the stop flag even when
// the endpoint is silent (WASAPI doesn't signal the event at
// all for pure-silence streams on some driver versions).
if h_event.wait_for_event(100).is_err() {
continue;
}
// Drain all packets available since the last wake — there
// can be several queued if we were preempted.
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let frames_available = match capture_client.get_next_nbr_frames() {
Ok(Some(n)) if n > 0 => n,
Ok(_) => break,
Err(e) => {
eprintln!(
"screen-audio {capture_id}: get_next_nbr_frames: {e:?}"
);
break;
}
};
let bytes_needed =
frames_available as usize * block_align as usize;
let mut raw = vec![0u8; bytes_needed];
if let Err(e) = capture_client.read_from_device(&mut raw) {
eprintln!(
"screen-audio {capture_id}: read_from_device: {e:?}"
);
break;
}
// Decode PCM into interleaved f32 at the device's native
// rate + channel count.
let decoded = decode_pcm(
&raw,
input_channels,
bits_per_sample,
&sample_type,
);
// Channel-fold → 2ch, then resample → 48kHz.
let stereo = to_stereo(&decoded, input_channels);
let out_samples = resample_linear_stereo(
&stereo,
input_rate,
OUTPUT_SAMPLE_RATE,
&mut last_stereo,
);
if out_samples.is_empty() {
continue;
}
// Pack f32s as little-endian bytes then base64. IPC-wise
// this is ~1.3× the raw byte count versus 510× for a
// JSON array of floats, which is the difference between
// "fine" and "wastes a CPU core" at 48kHz stereo.
let mut bytes = Vec::with_capacity(out_samples.len() * 4);
for s in &out_samples {
bytes.extend_from_slice(&s.to_le_bytes());
}
let samples_b64 =
base64::engine::general_purpose::STANDARD.encode(&bytes);
if channel
.send(AudioFramePayload {
capture_id,
sample_rate: OUTPUT_SAMPLE_RATE,
channels: OUTPUT_CHANNELS,
samples_base64: samples_b64,
})
.is_err()
{
// Frontend went away — stop cleanly.
stop.store(true, Ordering::Relaxed);
break;
}
}
}
let _ = audio_client.stop_stream();
Ok(())
}
// Convert a raw WASAPI buffer into interleaved f32 at the device's
// native channel count. Handles the three formats that actually show
// up on Windows render endpoints: f32 (most modern hardware), i16
// (older onboard codecs), and i32 (pro audio interfaces). Anything
// else falls through to zeros so a weird format doesn't crash the
// share — the user will notice silence and can retry.
fn decode_pcm(
raw: &[u8],
channels: u16,
bits_per_sample: u16,
sample_type: &SampleType,
) -> Vec<f32> {
match (sample_type, bits_per_sample) {
(SampleType::Float, 32) => {
let mut out = Vec::with_capacity(raw.len() / 4);
for chunk in raw.chunks_exact(4) {
out.push(f32::from_le_bytes([
chunk[0], chunk[1], chunk[2], chunk[3],
]));
}
out
}
(SampleType::Int, 16) => {
let scale = 1.0_f32 / (i16::MAX as f32);
let mut out = Vec::with_capacity(raw.len() / 2);
for chunk in raw.chunks_exact(2) {
let s = i16::from_le_bytes([chunk[0], chunk[1]]);
out.push(s as f32 * scale);
}
out
}
(SampleType::Int, 32) => {
let scale = 1.0_f32 / (i32::MAX as f32);
let mut out = Vec::with_capacity(raw.len() / 4);
for chunk in raw.chunks_exact(4) {
let s = i32::from_le_bytes([
chunk[0], chunk[1], chunk[2], chunk[3],
]);
out.push(s as f32 * scale);
}
out
}
_ => {
// Unknown format — emit silence of the right frame count
// so downstream math stays correct.
let bytes_per_frame =
(bits_per_sample as usize / 8) * channels as usize;
let frames = if bytes_per_frame == 0 {
0
} else {
raw.len() / bytes_per_frame
};
vec![0.0; frames * channels as usize]
}
}
}
// Down- or up-mix to stereo. Surround layouts fold L+R only (center
// + surrounds get dropped) which is the simplest defensible choice
// for screen-share audio — most content is LR-centric and a proper
// ITU-R BS.775 downmix would pull in matrix coefficients we'd rather
// avoid in v1.
fn to_stereo(interleaved: &[f32], channels: u16) -> Vec<f32> {
if channels == 0 || interleaved.is_empty() {
return Vec::new();
}
if channels == 2 {
return interleaved.to_vec();
}
let ch = channels as usize;
let frames = interleaved.len() / ch;
let mut out = Vec::with_capacity(frames * 2);
if channels == 1 {
for i in 0..frames {
let s = interleaved[i];
out.push(s);
out.push(s);
}
} else {
for i in 0..frames {
let base = i * ch;
out.push(interleaved[base]);
out.push(interleaved[base + 1]);
}
}
out
}
// Linear-interpolation resampler for interleaved stereo f32. Not the
// prettiest option theoretically, but at 44.1→48 the audible
// artefacts stay below threshold for speech + game/music content. The
// `last_stereo` state preserves the final frame across invocations so
// the interpolation at the buffer boundary doesn't produce a click.
fn resample_linear_stereo(
input_stereo: &[f32],
input_rate: u32,
output_rate: u32,
last_stereo: &mut [f32; 2],
) -> Vec<f32> {
if input_stereo.is_empty() {
return Vec::new();
}
if input_rate == output_rate {
last_stereo[0] = input_stereo[input_stereo.len() - 2];
last_stereo[1] = input_stereo[input_stereo.len() - 1];
return input_stereo.to_vec();
}
let ratio = output_rate as f64 / input_rate as f64;
let in_frames = input_stereo.len() / 2;
let out_frames = (in_frames as f64 * ratio).floor() as usize;
if out_frames == 0 {
last_stereo[0] = input_stereo[input_stereo.len() - 2];
last_stereo[1] = input_stereo[input_stereo.len() - 1];
return Vec::new();
}
let mut out = Vec::with_capacity(out_frames * 2);
let prev_l = last_stereo[0];
let prev_r = last_stereo[1];
for i in 0..out_frames {
let src_pos = i as f64 / ratio;
let src_frame = src_pos.floor() as i64;
let frac = (src_pos - src_frame as f64) as f32;
// `src_frame == -1` comes up for the very first output frame
// when ratio > 1 — interpolate against the previous buffer's
// final sample to bridge the two.
let (l0, r0) = if src_frame < 0 {
(prev_l, prev_r)
} else {
let idx = (src_frame as usize).min(in_frames - 1) * 2;
(input_stereo[idx], input_stereo[idx + 1])
};
let next = ((src_frame + 1) as usize).min(in_frames - 1);
let l1 = input_stereo[next * 2];
let r1 = input_stereo[next * 2 + 1];
out.push(l0 + (l1 - l0) * frac);
out.push(r0 + (r1 - r0) * frac);
}
last_stereo[0] = input_stereo[input_stereo.len() - 2];
last_stereo[1] = input_stereo[input_stereo.len() - 1];
out
}
}
@@ -0,0 +1,266 @@
// Native screen / window capture pipeline. Spawns a Rust thread per active
// capture that grabs frames via xcap, downscales them to the user's target
// resolution, encodes JPEG, and streams each frame to the JS side through
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
// MediaStream via captureStream() — that stream is what LiveKit publishes.
// Net result: the user picks a source in our custom picker and the share
// starts directly, without the OS/browser picker appearing.
//
// JPEG on the Rust side + decode on the JS side introduces a second
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
// 50150KB each, so 30fps = 25MB/s of base64 traffic, which is fine.
use base64::Engine;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use tauri::ipc::Channel;
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
struct Session {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FramePayload {
pub capture_id: u32,
pub width: u32,
pub height: u32,
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
/// `createImageBitmap(blob)`.
pub jpeg_base64: String,
}
enum Source {
Window(xcap::Window),
Monitor(xcap::Monitor),
}
/// Start a continuous capture for the given source id and begin streaming
/// JPEG frames via the provided channel. Returns a numeric capture id that
/// must be passed to `stop_screen_capture` to tear the pipeline down.
#[tauri::command]
pub fn start_screen_capture(
source_id: String,
max_width: u32,
max_height: u32,
fps: u32,
channel: Channel<FramePayload>,
) -> Result<u32, String> {
let clamped_fps = fps.clamp(5, 30);
let clamped_w = max_width.max(320).min(3840);
let clamped_h = max_height.max(180).min(2160);
// Probe once on the command thread so we can surface a clear error
// before spawning; the actual capture handle is re-resolved inside the
// worker thread (xcap::Window holds a Windows HWND which is !Send).
if find_source(&source_id).is_none() {
return Err(format!("screen source {source_id} not found"));
}
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let source_id_owned = source_id;
let handle = thread::Builder::new()
.name(format!("screen-capture-{capture_id}"))
.spawn(move || {
capture_loop(
source_id_owned,
clamped_w,
clamped_h,
clamped_fps,
capture_id,
channel,
stop_clone,
);
})
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
sessions().lock().unwrap().insert(
capture_id,
Session {
stop,
handle: Some(handle),
},
);
Ok(capture_id)
}
/// Signal the capture thread to stop and join it. Safe to call more than
/// once — missing ids are silently no-ops so the JS side doesn't need to
/// track whether a stop was already issued by the OS "stop sharing" path.
#[tauri::command]
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
let session = sessions().lock().unwrap().remove(&capture_id);
let Some(mut session) = session else {
return Ok(());
};
session.stop.store(true, Ordering::Relaxed);
if let Some(handle) = session.handle.take() {
// Best-effort join. If the thread is stuck in a long OS capture
// call we don't want to hang the command — detach after a brief
// wait by dropping the handle.
let _ = handle.join();
}
Ok(())
}
fn capture_loop(
source_id: String,
max_w: u32,
max_h: u32,
fps: u32,
capture_id: u32,
channel: Channel<FramePayload>,
stop: Arc<AtomicBool>,
) {
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
let jpeg_quality: u8 = 72;
// Re-resolve the source inside the worker thread — xcap::Window holds
// an HWND which is !Send so we can't move it across threads. Caching
// the handle for the lifetime of the loop keeps per-frame cost to the
// actual pixel capture + encode.
let source = match find_source(&source_id) {
Some(s) => s,
None => {
eprintln!("screen-capture {capture_id}: source vanished before capture started");
return;
}
};
while !stop.load(Ordering::Relaxed) {
let frame_start = Instant::now();
let img_result = match &source {
Source::Window(w) => w.capture_image(),
Source::Monitor(m) => m.capture_image(),
};
let img = match img_result {
Ok(i) => i,
Err(err) => {
eprintln!("screen-capture {capture_id}: capture failed: {err}");
// Brief backoff before retrying — transient Windows GDI
// errors (e.g. during screen lock) tend to recover within
// a frame or two.
thread::sleep(frame_interval);
continue;
}
};
let (raw_w, raw_h) = img.dimensions();
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
image::imageops::resize(
&img,
tgt_w,
tgt_h,
image::imageops::FilterType::Triangle,
)
} else {
img
};
// JPEG doesn't support alpha; strip it before encoding.
let rgb = rgba_to_rgb(&scaled);
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
{
use image::codecs::jpeg::JpegEncoder;
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
if let Err(err) =
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
{
eprintln!("screen-capture {capture_id}: encode failed: {err}");
thread::sleep(frame_interval);
continue;
}
}
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
let send_result = channel.send(FramePayload {
capture_id,
width: tgt_w,
height: tgt_h,
jpeg_base64,
});
if send_result.is_err() {
// Frontend went away (window closed, renderer crashed).
break;
}
// Pace to target framerate. If the capture + encode already took
// longer than one frame interval, yield a millisecond to avoid
// pegging a single core when the target is unreachable.
let elapsed = frame_start.elapsed();
if elapsed < frame_interval {
thread::sleep(frame_interval - elapsed);
} else {
thread::sleep(Duration::from_millis(1));
}
}
}
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
let (w, h) = buf.dimensions();
let mut out = Vec::with_capacity((w * h * 3) as usize);
for p in buf.pixels() {
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
}
out
}
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
if w == 0 || h == 0 {
return (w, h);
}
let scale = (max_w as f32 / w as f32)
.min(max_h as f32 / h as f32)
.min(1.0);
if scale >= 1.0 {
return (w, h);
}
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
(new_w, new_h)
}
fn find_source(source_id: &str) -> Option<Source> {
if let Some(rest) = source_id.strip_prefix("window:") {
let raw = rest.strip_suffix(":0")?;
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
return windows
.into_iter()
.find(|w| w.id() == raw_id)
.map(Source::Window);
}
if let Some(rest) = source_id.strip_prefix("screen:") {
let raw = rest.strip_suffix(":0")?;
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
return monitors
.into_iter()
.find(|m| m.id() == raw_id)
.map(Source::Monitor);
}
None
}
@@ -0,0 +1,335 @@
// Source enumeration for the Discord-style screen-share picker. Split
// into two commands on the slow-vs-fast axis:
//
// - list_screen_sources — metadata only (no thumbnails). Fast; the
// picker shows names + placeholders instantly.
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
// the id returned from the list.
//
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
// command thread pool captures them in parallel — wall-clock time ends up
// bounded by the *slowest* source rather than the sum of all captures.
// The `id` field is emitted in Chromium's internal desktopCapturer format
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
// it as the source key for the native capture pipeline in screen_capture.
//
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
// Thumbnails are captured at native resolution, then letterbox-downscaled
// to fit a 320×180 box to keep the base64 payload small.
use base64::Engine;
use image::{ImageBuffer, Rgba};
use serde::Serialize;
// Thumbnail dimensions tuned for the picker grid: even smaller than the
// first pass because we're now streaming raw JPEG bytes (no base64) —
// smaller payload = less IPC postMessage work on the main thread. At
// 192×108 / Q60 the typical window thumbnail is 510 KB and decodes to
// the grid in a frame or two.
const THUMB_MAX_W: u32 = 192;
const THUMB_MAX_H: u32 = 108;
const THUMB_JPEG_QUALITY: u8 = 60;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ScreenSource {
/// Chromium-format source id — stable within one enumeration call.
pub id: String,
/// Human-readable label for the picker (monitor name or window title).
pub name: String,
/// Discriminator for the grid grouping.
pub kind: &'static str,
/// Base64-encoded PNG, no data-URL prefix. None when the capture
/// fails (minimised window, permission-denied surface, transient
/// race). The UI renders a name-only card in that case.
pub thumbnail_png: Option<String>,
/// Native width of the full-res source — mostly informational, used
/// by the UI for aspect-ratio styling of the card.
pub width: u32,
pub height: u32,
}
// Fast metadata-only enumeration. No image capture happens here — that's
// why it returns in tens of milliseconds instead of the multi-second
// wait the single-shot enumerate_screen_sources command had.
#[tauri::command]
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
let mut out: Vec<ScreenSource> = Vec::new();
append_monitor_metadata(&mut out);
append_window_metadata(&mut out);
Ok(out)
}
// One-shot thumbnail capture by source id. Called N times in parallel from
// the frontend after the list lands so the total wall-clock is bounded by
// the slowest capture rather than the sum. Legacy base64 variant — kept
// for rollback; the preferred path is `capture_screen_source_thumbnail_bytes`.
#[tauri::command]
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
return Ok(capture_monitor_by_id(raw));
}
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
return Ok(capture_window_by_id(raw));
}
Err(format!("unknown source id format: {source_id}"))
}
// Binary variant: returns raw JPEG bytes wrapped in `tauri::ipc::Response`
// so Tauri ships them over IPC without JSON-encoding / base64. On the JS
// side, `invoke` resolves to an ArrayBuffer that we wrap in a Blob and
// expose via `URL.createObjectURL` — skips the base64-decode step
// entirely and keeps the main thread responsive during fan-in.
//
// The return type MUST be `Response` directly (not `Result<Response, E>`):
// a Result wrapper forces Tauri to JSON-serialise the variant so the
// frontend gets a JSON object instead of raw bytes. Failures — bad id
// format, capture errors, source vanished — all funnel into an empty
// byte buffer; the caller treats `byteLength === 0` as the "no thumbnail"
// signal.
#[tauri::command]
pub fn capture_screen_source_thumbnail_bytes(source_id: String) -> tauri::ipc::Response {
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
capture_monitor_bytes_by_id(raw).unwrap_or_default()
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
capture_window_bytes_by_id(raw).unwrap_or_default()
} else {
eprintln!("capture_screen_source_thumbnail_bytes: unknown id format: {source_id}");
Vec::new()
};
tauri::ipc::Response::new(bytes)
}
fn strip_zero_suffix(s: &str) -> Option<&str> {
s.strip_suffix(":0")
}
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
// fall back during rollout if the split pair throws; marked dead_code so
// the linker doesn't grumble when only the split variant is wired up.
#[allow(dead_code)]
#[tauri::command]
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
let mut out: Vec<ScreenSource> = Vec::new();
append_monitors(&mut out);
append_windows(&mut out);
Ok(out)
}
// ---------------------------------------------------------------------------
// Monitors
// ---------------------------------------------------------------------------
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
let monitors = match xcap::Monitor::all() {
Ok(m) => m,
Err(err) => {
eprintln!("xcap Monitor::all failed: {err}");
return;
}
};
for (idx, m) in monitors.iter().enumerate() {
out.push(ScreenSource {
id: format!("screen:{}:0", m.id()),
name: monitor_label(m, idx),
kind: "screen",
thumbnail_png: None,
width: m.width(),
height: m.height(),
});
}
}
#[allow(dead_code)]
fn append_monitors(out: &mut Vec<ScreenSource>) {
let monitors = match xcap::Monitor::all() {
Ok(m) => m,
Err(err) => {
eprintln!("xcap Monitor::all failed: {err}");
return;
}
};
for (idx, m) in monitors.iter().enumerate() {
out.push(ScreenSource {
id: format!("screen:{}:0", m.id()),
name: monitor_label(m, idx),
kind: "screen",
thumbnail_png: capture_monitor_thumbnail(m),
width: m.width(),
height: m.height(),
});
}
}
fn capture_monitor_by_id(raw: &str) -> Option<String> {
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
capture_monitor_thumbnail(&target)
}
fn capture_monitor_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
let image = target.capture_image().ok()?;
encode_scaled_jpeg_bytes(image)
}
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
let name = m.name();
if name.is_empty() {
format!("Bildschirm {}", idx + 1)
} else {
name.to_string()
}
}
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
let image = m.capture_image().ok()?;
encode_scaled_jpeg(image)
}
// ---------------------------------------------------------------------------
// Windows
// ---------------------------------------------------------------------------
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
let windows = match xcap::Window::all() {
Ok(w) => w,
Err(err) => {
eprintln!("xcap Window::all failed: {err}");
return;
}
};
for w in windows.iter() {
if !is_shareable_window(w) {
continue;
}
let title = w.title();
if title.trim().is_empty() {
continue;
}
out.push(ScreenSource {
id: format!("window:{}:0", w.id()),
name: title.to_string(),
kind: "window",
thumbnail_png: None,
width: w.width(),
height: w.height(),
});
}
}
#[allow(dead_code)]
fn append_windows(out: &mut Vec<ScreenSource>) {
let windows = match xcap::Window::all() {
Ok(w) => w,
Err(err) => {
eprintln!("xcap Window::all failed: {err}");
return;
}
};
for w in windows.iter() {
if !is_shareable_window(w) {
continue;
}
let title = w.title();
if title.trim().is_empty() {
continue;
}
out.push(ScreenSource {
id: format!("window:{}:0", w.id()),
name: title.to_string(),
kind: "window",
thumbnail_png: capture_window_thumbnail(w),
width: w.width(),
height: w.height(),
});
}
}
fn capture_window_by_id(raw: &str) -> Option<String> {
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
capture_window_thumbnail(&target)
}
fn capture_window_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
let image = target.capture_image().ok()?;
encode_scaled_jpeg_bytes(image)
}
fn is_shareable_window(w: &xcap::Window) -> bool {
if w.is_minimized() {
return false;
}
let width = w.width();
let height = w.height();
// Tooltips, invisible tray-helpers, etc. sit at or near zero size —
// they'd clutter the picker grid and usually can't be captured anyway.
if width < 80 || height < 60 {
return false;
}
true
}
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
let image = w.capture_image().ok()?;
encode_scaled_jpeg(image)
}
// ---------------------------------------------------------------------------
// Scaling + encoding
// ---------------------------------------------------------------------------
// Letterbox-shrink the captured image so the long edge is at most
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
// (tiny windows stay their captured size). Returns raw JPEG bytes — the
// binary-IPC path ships these directly, the legacy base64 wrapper
// (`encode_scaled_jpeg`) wraps in base64 for the old command.
fn encode_scaled_jpeg_bytes(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<Vec<u8>> {
let (w, h) = src.dimensions();
if w == 0 || h == 0 {
return None;
}
let scale = (THUMB_MAX_W as f32 / w as f32)
.min(THUMB_MAX_H as f32 / h as f32)
.min(1.0);
let scaled = if scale < 1.0 {
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle)
} else {
src
};
// JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB
// buffer first. Alpha carries no info for a visible thumbnail anyway.
let (sw, sh) = (scaled.width(), scaled.height());
let mut rgb: Vec<u8> = Vec::with_capacity((sw * sh * 3) as usize);
for p in scaled.pixels() {
rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
}
let mut buf: Vec<u8> = Vec::with_capacity((sw * sh / 8) as usize);
{
use image::codecs::jpeg::JpegEncoder;
let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY);
encoder
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
.ok()?;
}
Some(buf)
}
// Legacy base64 wrapper — used by `capture_screen_source_thumbnail`
// (Result<Option<String>, String>) which predates the binary variant.
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
let bytes = encode_scaled_jpeg_bytes(src)?;
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
+5 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.10.1", "version": "0.11.0",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
@@ -42,8 +42,10 @@
}, },
"plugins": { "plugins": {
"updater": { "updater": {
"endpoints": ["https://github.com/byGalax/chat-app/releases/latest/download/latest.json"], "endpoints": [
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK", "https://update.netralax.cloud/windows/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1Mzc0QjVBQUZEQTA3RUIKUldUckI5cXZXa3MzdGM3QkE4WWFPd3NnVzRZeXdpcUM0eUtjRDlGN09ySEdzNXhLNlo3azBPajYK",
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
} }
@@ -20,6 +20,10 @@ interface Props {
deafened: boolean; deafened: boolean;
onToggleMute: () => void; onToggleMute: () => void;
onToggleShare: () => void; onToggleShare: () => void;
/** Right-click on the share button opens the quality picker dialog while
* left-click just starts with last-used settings. Optional so pages that
* don't need the advanced path (mobile, etc.) can skip it. */
onShareContextMenu?: (e: React.MouseEvent) => void;
onToggleVideo?: () => void; onToggleVideo?: () => void;
onToggleDeafen: () => void; onToggleDeafen: () => void;
onHangup: () => void; onHangup: () => void;
@@ -27,6 +31,7 @@ interface Props {
/** Toggle the in-call soundboard popover. Active = panel currently open. */ /** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void; onToggleSoundboard?: () => void;
soundboardOpen?: boolean; soundboardOpen?: boolean;
participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */ /** Compact variant used inside the docked call (36px buttons). */
compact?: boolean; compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */ /** Glass variant used when controls float on fullscreen cinema mode. */
@@ -42,12 +47,14 @@ export function CallControls({
deafened, deafened,
onToggleMute, onToggleMute,
onToggleShare, onToggleShare,
onShareContextMenu,
onToggleVideo, onToggleVideo,
onToggleDeafen, onToggleDeafen,
onHangup, onHangup,
onOpenParticipants, onOpenParticipants,
onToggleSoundboard, onToggleSoundboard,
soundboardOpen = false, soundboardOpen = false,
participantsOpen = false,
compact = false, compact = false,
glass = false, glass = false,
disabledMedia = false, disabledMedia = false,
@@ -111,6 +118,7 @@ export function CallControls({
active={sharing} active={sharing}
activeTone="accent" activeTone="accent"
onClick={onToggleShare} onClick={onToggleShare}
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
disabled={disabledMedia} disabled={disabledMedia}
glass={glass} glass={glass}
className={btnSize} className={btnSize}
@@ -137,6 +145,9 @@ export function CallControls({
<CallButton <CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })} label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
onClick={onOpenParticipants} onClick={onOpenParticipants}
active={participantsOpen}
activeTone="accent"
dataTrigger="participants"
glass={glass} glass={glass}
className={btnSize} className={btnSize}
> >
@@ -159,24 +170,30 @@ export function CallControls({
interface CallButtonProps { interface CallButtonProps {
label: string; label: string;
onClick: () => void; onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
disabled?: boolean; disabled?: boolean;
active?: boolean; active?: boolean;
activeTone?: 'accent' | 'danger'; activeTone?: 'accent' | 'danger';
tone?: 'default' | 'danger'; tone?: 'default' | 'danger';
glass?: boolean; glass?: boolean;
className?: string; className?: string;
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
* when the user is toggling their own trigger. */
dataTrigger?: string;
children: React.ReactNode; children: React.ReactNode;
} }
function CallButton({ function CallButton({
label, label,
onClick, onClick,
onContextMenu,
disabled, disabled,
active, active,
activeTone = 'accent', activeTone = 'accent',
tone = 'default', tone = 'default',
glass = false, glass = false,
className = '', className = '',
dataTrigger,
children, children,
}: CallButtonProps) { }: CallButtonProps) {
const base = const base =
@@ -200,11 +217,13 @@ function CallButton({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
onContextMenu={onContextMenu}
disabled={disabled} disabled={disabled}
aria-label={label} aria-label={label}
aria-pressed={active} aria-pressed={active}
title={label} title={label}
className={`${base} ${toneClass} ${className}`} className={`${base} ${toneClass} ${className}`}
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
> >
{children} {children}
</button> </button>
@@ -75,7 +75,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
} = props; } = props;
const small = size === 'small'; const small = size === 'small';
const borderClass = speaking // Split the speaking indicator per-mode so we don't stack a tile border
// + inset glow on top of the avatar pulse (visual double-chrome). Video
// tiles get the border (the avatar is hidden behind the stream so the
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
// pulse rendered inside AudioContent.
const videoSpeaking = speaking && video;
const borderClass = videoSpeaking
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400' ? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
: focused : focused
? 'border-accent' ? 'border-accent'
@@ -98,9 +104,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
<AudioContent {...props} small={small} /> <AudioContent {...props} small={small} />
)} )}
{/* Speaking indicator visible regardless of content type (video or {/* Video-only speaking indicator. Audio tiles use the avatar pulse
audio). z-10 ensures it sits above the video element. */} from AudioContent so we don't double-render chrome. z-10 keeps
{speaking && ( it above the video element. */}
{videoSpeaking && (
<span <span
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]" className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
+35 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
@@ -158,6 +158,7 @@ function PipCall() {
const active = const active =
state.kind === 'connected' || state.kind === 'connected' ||
state.kind === 'connecting' || state.kind === 'connecting' ||
state.kind === 'reconnecting' ||
state.kind === 'outgoing'; state.kind === 'outgoing';
if (!active) return null; if (!active) return null;
@@ -172,6 +173,13 @@ function PipCall() {
: conv?.peer?.displayName ?? '—'; : conv?.peer?.displayName ?? '—';
const participantCount = 1 + remoteParticipants.length; const participantCount = 1 + remoteParticipants.length;
const someoneSharing = remoteScreenShares.length > 0; const someoneSharing = remoteScreenShares.length > 0;
// Duration ticks while connected or reconnecting (LiveKit holds the room
// across reconnects, so the timer shouldn't reset on a wobble). Absent
// on outgoing/connecting where the call hasn't started yet.
const startedAt =
state.kind === 'connected' || state.kind === 'reconnecting'
? state.startedAt
: null;
return ( return (
<div <div
@@ -196,7 +204,11 @@ function PipCall() {
aria-hidden="true" aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot" className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
/> />
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span> <span className="tabular-nums">
{startedAt
? <PipDuration startedAt={startedAt} />
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
</span>
</p> </p>
</div> </div>
<button <button
@@ -213,3 +225,24 @@ function PipCall() {
</div> </div>
); );
} }
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
// deliberately — the two widgets have different typography + tabular
// contexts, and extracting a shared component would be heavier than the
// 8-line countup it replaces.
function PipDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
const total = Math.max(
0,
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
);
const hh = Math.floor(total / 3600);
const mm = Math.floor((total % 3600) / 60);
const ss = total % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
}
+402 -37
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client'; import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client'; import { Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -11,12 +11,18 @@ import {
type PttSettings, type PttSettings,
subscribePttSettings, subscribePttSettings,
} from '../lib/pttSettings'; } from '../lib/pttSettings';
import {
listSounds,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile'; import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog'; import { ScreenShareContextMenu } from './ScreenShareContextMenu';
import { ScreenSourcePicker } from './ScreenSourcePicker';
import { ScreenShareViewer } from './ScreenShareViewer'; import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel'; import { SoundboardPanel } from './SoundboardPanel';
@@ -74,23 +80,105 @@ export function InCallPanel({ conversation }: Props) {
hangup, hangup,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
micError,
clearMicError,
retryMic,
dismissedShareUserIds,
} = useCall(); } = useCall();
const { session } = useAuth(); const { session } = useAuth();
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room); const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false); const [soundboardOpen, setSoundboardOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState< const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null { userId: string; displayName: string; x: number; y: number } | null
>(null); >(null);
const [shareMenu, setShareMenu] = useState<
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
>(null);
// Soundboard-count so the in-call bar only surfaces the music button when
// the user actually has something to play. Matches Discord's "hide soundboard
// when empty" behaviour — no point dangling a button that opens to a blank
// "Keine Sounds" popover. Subscribes live so a sound added mid-call makes
// the button pop in without reopening the call.
const [soundboardCount, setSoundboardCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setSoundboardCount(all.length);
} catch {
if (!cancelled) setSoundboardCount(0);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
// Close the popover if the user just cleared their last sound while it was
// open — keeps the panel from lingering over an empty list.
useEffect(() => {
if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]);
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => { // Active-speaker auto-focus uses "who most recently started speaking"
// rather than "exactly one speaker" — matches Discord more closely and
// handles the case where two people talk briefly without the focus
// collapsing to nobody.
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const id of activeSpeakers) {
if (!prevActiveSpeakersRef.current.has(id)) {
setLastStartedSpeakerId(id);
break;
}
}
prevActiveSpeakersRef.current = new Set(activeSpeakers);
}, [activeSpeakers]);
// Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can
// stop your own share from the control bar.
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return; if (tile.self) return;
if (tile.kind !== 'user') return;
e.preventDefault(); e.preventDefault();
setVolumeMenu({ if (tile.kind === 'user') {
setShareMenu(null);
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
});
return;
}
// Screen-tile. Check whether the participant has a published screen-share
// audio track so the menu can hide the volume/mute rows when there's
// nothing to control.
const participant = remoteParticipants.find((p) => p.identity === tile.userId);
let hasAudio = false;
if (participant) {
for (const pub of participant.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio) {
hasAudio = true;
break;
}
}
}
setVolumeMenu(null);
setShareMenu({
userId: tile.userId, userId: tile.userId,
displayName: tile.displayName, displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
hasAudio,
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
}); });
@@ -99,6 +187,7 @@ export function InCallPanel({ conversation }: Props) {
const active = const active =
(state.kind === 'connected' || (state.kind === 'connected' ||
state.kind === 'connecting' || state.kind === 'connecting' ||
state.kind === 'reconnecting' ||
state.kind === 'outgoing') && state.kind === 'outgoing') &&
state.conversationId === conversation.id; state.conversationId === conversation.id;
if (!active) return null; if (!active) return null;
@@ -114,9 +203,20 @@ export function InCallPanel({ conversation }: Props) {
remoteMute, remoteMute,
isScreenSharing, isScreenSharing,
isCameraEnabled, isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), // Sharer ids that survived the user's dismiss-set. If the user did
// "Zuschauen beenden" on someone's share, they drop out of the tile
// grid until that sharer stops + restarts (TrackUnsubscribed clears
// dismissedShareUserIds — see CallContext).
remoteSharerIds: new Set(
remoteScreenShares
.map((s) => s.participantId)
.filter((id) => !dismissedShareUserIds.has(id)),
),
}); });
// Duration keeps ticking during reconnecting so the user sees the call is
// still alive — but the status label below takes precedence in the header
// so the "Verbinde neu…" message is prominent, not buried under the timer.
const duration = const duration =
state.kind === 'connected' state.kind === 'connected'
? <LiveDuration startedAt={state.startedAt} /> ? <LiveDuration startedAt={state.startedAt} />
@@ -127,15 +227,17 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.outgoing_ringing') ? t('app:call.outgoing_ringing')
: state.kind === 'connecting' : state.kind === 'connecting'
? t('app:call.connecting') ? t('app:call.connecting')
: remoteParticipants.length === 0 : state.kind === 'reconnecting'
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) ? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
: t('app:call.connected'); : remoteParticipants.length === 0
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// A screen-share tile becomes the auto-focus target when no one explicitly // Screen shares no longer auto-promote — the user opts in by clicking the
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can // "Bildschirm anschauen" overlay, which also toggles whether the audio
// distinguish a user's own avatar tile from their screen tile. // plays. Focus falls back to the first tile so focus-mode always has
const screenTile = tiles.find((p) => p.kind === 'screen'); // something to show when no tile was explicitly picked.
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null; const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0]; const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
const controls = ( const controls = (
@@ -145,40 +247,86 @@ export function InCallPanel({ conversation }: Props) {
video={isCameraEnabled} video={isCameraEnabled}
deafened={isDeafened} deafened={isDeafened}
onToggleMute={toggleMute} onToggleMute={toggleMute}
// Click opens the Discord-style source picker (thumbnails + quality +
// audio). Clicking again while a share is live stops it. Right-click
// also opens the picker in case the user wants to swap sources.
onToggleShare={() => { onToggleShare={() => {
if (isScreenSharing) { if (isScreenSharing) {
void stopScreenShare(); void stopScreenShare();
} else { } else {
setShareDialogOpen(true); setPickerOpen(true);
} }
}} }}
onShareContextMenu={(e) => {
e.preventDefault();
if (!isScreenSharing) setPickerOpen(true);
}}
onToggleVideo={() => void toggleCamera()} onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen} onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)} onOpenParticipants={() => setParticipantsOpen((v) => !v)}
soundboardOpen={soundboardOpen} participantsOpen={participantsOpen}
// Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count
// aus IndexedDB geladen ist (null), auch nicht rendern — verhindert
// einen Flash des Buttons beim Call-Start wenn der User eh keine
// Sounds hat.
{...(soundboardCount && soundboardCount > 0
? {
onToggleSoundboard: () => setSoundboardOpen((v) => !v),
soundboardOpen,
}
: {})}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'} disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
/> />
); );
// Only participant-tiles feed the popover (screen-share tiles aren't
// people). Own row is always first, rest follows conversation order.
const participantRows: ParticipantRow[] = tiles
.filter((t) => t.kind === 'user')
.map((t) => ({
userId: t.userId,
displayName: t.displayName,
avatarUrl: t.avatarUrl,
self: t.self,
muted: t.muted,
deafened: t.deafened,
}));
if (callMode === 'fullscreen') { if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone, OR // In fullscreen, a "manual focus" = user explicitly picked someone OR
// someone is sharing a screen, OR exactly one non-self speaker is talking // the person who most recently started speaking (tracked in
// (auto-promote). Without that we show an even grid of all participants // lastStartedSpeakerId). Screen shares are no longer an auto-focus
// (Discord default). Clicking a tile switches to the big-speaker layout. // trigger; they stay as equal-size grid tiles until the user clicks
const speakingNonSelf = tiles.filter( // one. "Most recent speaker" beats "exactly one currently speaking"
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user', // because two people briefly overlapping shouldn't kick us out of
); // auto-focus.
const autoSpeaker = const autoSpeaker =
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1 focusedId === null && lastStartedSpeakerId !== null
? speakingNonSelf[0] ? tiles.find(
(t) =>
t.kind === 'user' &&
!t.self &&
t.userId === lastStartedSpeakerId,
)
: undefined; : undefined;
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined; const hasFocus = focusedId !== null || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined; const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return ( return (
<> <>
{micError && (
<div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
<div className="pointer-events-auto max-w-[520px] w-full">
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
</div>
</div>
)}
<FullscreenCall <FullscreenCall
tiles={tiles} tiles={tiles}
speaker={effectiveSpeaker} speaker={effectiveSpeaker}
@@ -191,8 +339,18 @@ export function InCallPanel({ conversation }: Props) {
// Toggle: click the already-focused tile to return to grid. // Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id); setFocusedId(focusedId === id ? null : id);
}} }}
onTileContextMenu={openVolumeMenu} onTileContextMenu={openTileContextMenu}
controls={controls} controls={controls}
// Any active popover / menu / banner pins the controls so the user
// can interact with them without the chrome fading out under their
// cursor while they're mid-action.
keepControlsVisible={
soundboardOpen ||
participantsOpen ||
volumeMenu !== null ||
shareMenu !== null ||
micError !== null
}
/> />
{volumeMenu && ( {volumeMenu && (
<ParticipantVolumeMenu <ParticipantVolumeMenu
@@ -203,10 +361,26 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)} onClose={() => setVolumeMenu(null)}
/> />
)} )}
{shareMenu && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover <SoundboardPopover
open={soundboardOpen} open={soundboardOpen}
onClose={() => setSoundboardOpen(false)} onClose={() => setSoundboardOpen(false)}
/> />
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
</> </>
); );
} }
@@ -253,6 +427,14 @@ export function InCallPanel({ conversation }: Props) {
<ModeToggles mode={callMode} onChange={setCallMode} /> <ModeToggles mode={callMode} onChange={setCallMode} />
</div> </div>
{micError && (
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
)}
<CallStage <CallStage
tiles={tiles} tiles={tiles}
speaker={speaker} speaker={speaker}
@@ -265,7 +447,7 @@ export function InCallPanel({ conversation }: Props) {
setFocusedId(id); setFocusedId(id);
if (callMode === 'grid') setCallMode('focus'); if (callMode === 'grid') setCallMode('focus');
}} }}
onTileContextMenu={openVolumeMenu} onTileContextMenu={openTileContextMenu}
compact compact
/> />
@@ -273,9 +455,9 @@ export function InCallPanel({ conversation }: Props) {
<PttHint /> <PttHint />
<ScreenShareDialog <ScreenSourcePicker
open={shareDialogOpen} open={pickerOpen}
onClose={() => setShareDialogOpen(false)} onClose={() => setPickerOpen(false)}
onStart={async (opts) => { onStart={async (opts) => {
await startScreenShare(opts); await startScreenShare(opts);
}} }}
@@ -291,10 +473,28 @@ export function InCallPanel({ conversation }: Props) {
/> />
)} )}
{shareMenu && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover <SoundboardPopover
open={soundboardOpen} open={soundboardOpen}
onClose={() => setSoundboardOpen(false)} onClose={() => setSoundboardOpen(false)}
/> />
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
</section> </section>
); );
} }
@@ -583,6 +783,7 @@ function TileRender({
return ( return (
<div <div
onClick={onClick} onClick={onClick}
onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')} className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
> >
<ScreenShareViewer <ScreenShareViewer
@@ -761,8 +962,13 @@ interface FullscreenProps {
onFocusTile: (id: string) => void; onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void; onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode; controls: React.ReactNode;
/** When true, controls stay visible regardless of mouse idle (used while
* a popover / menu / error banner is open). */
keepControlsVisible?: boolean;
} }
const CONTROLS_IDLE_MS = 5_000;
function FullscreenCall({ function FullscreenCall({
tiles, tiles,
speaker, speaker,
@@ -774,13 +980,47 @@ function FullscreenCall({
onFocusTile, onFocusTile,
onTileContextMenu, onTileContextMenu,
controls, controls,
keepControlsVisible = false,
}: FullscreenProps) { }: FullscreenProps) {
const [hintGone, setHintGone] = useState(false); const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
// Discord-style auto-hide: controls fade out after 5s of mouse idle in
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
// popover opening via keepControlsVisible) brings them back immediately.
const [controlsVisible, setControlsVisible] = useState(true);
// Session-only toggle to collapse the participant strip while watching a
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
// participants" — gives the focused content the full fullscreen height.
const [stripHidden, setStripHidden] = useState(false);
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500); const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id); return () => window.clearTimeout(id);
}, []); }, []);
useEffect(() => {
if (keepControlsVisible) {
setControlsVisible(true);
return;
}
let timer: number | null = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
const reset = () => {
setControlsVisible(true);
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
};
window.addEventListener('mousemove', reset);
window.addEventListener('touchstart', reset);
return () => {
if (timer !== null) window.clearTimeout(timer);
window.removeEventListener('mousemove', reset);
window.removeEventListener('touchstart', reset);
};
}, [keepControlsVisible]);
const hasFocus = speaker !== undefined; const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : []; const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
@@ -826,7 +1066,7 @@ function FullscreenCall({
: {})} : {})}
/> />
</div> </div>
{others.length > 0 && ( {others.length > 0 && !stripHidden && (
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2"> <div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.map((p) => ( {others.map((p) => (
<div <div
@@ -905,13 +1145,76 @@ function FullscreenCall({
</div> </div>
)} )}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2"> {/* Hide-participant-strip toggle, only meaningful when there's a focus
+ extras to hide. Fades alongside the bottom control bar so idle
fullscreen still goes clean. */}
{hasFocus && others.length > 0 && (
<button
type="button"
onClick={() => setStripHidden((v) => !v)}
aria-pressed={stripHidden}
title={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
aria-label={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
className={
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(controlsVisible
? 'pointer-events-auto opacity-100 '
: 'pointer-events-none opacity-0 ') +
(stripHidden
? 'border-accent bg-accent/20 text-accent-fg'
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
}
>
<StripToggleIcon hidden={stripHidden} />
</button>
)}
<div
className={
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
(controlsVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0')
}
>
{controls} {controls}
</div> </div>
</div> </div>
); );
} }
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
// MicOff/HeadphonesOff naming convention used elsewhere.
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
</svg>
);
}
function PttHint() { function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings()); const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []); useEffect(() => subscribePttSettings(setPtt), []);
@@ -925,3 +1228,65 @@ function PttHint() {
</p> </p>
); );
} }
// Non-terminal mic error banner. Shown inside the call panel when mic setup
// fails — the call itself stays alive, the user just can't be heard. Retry
// invokes the pipeline setup again with the current audioSettings so a
// permission granted in OS settings mid-call works without rejoin.
function MicErrorBanner({
message,
onRetry,
onDismiss,
}: {
message: string;
onRetry: () => void;
onDismiss: () => void;
}) {
return (
<div
role="alert"
className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
>
<MicOffIconInline />
<p className="flex-1 leading-relaxed">{message}</p>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
>
Erneut versuchen
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Schließen"
className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
function MicOffIconInline() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
<line x1="12" y1="19" x2="12" y2="23" />
</svg>
);
}
@@ -15,8 +15,8 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
role="group" role="group"
aria-label="Language" aria-label="Language"
className={ className={
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' + 'inline-flex items-center rounded-full border border-line bg-surface-2 p-0.5 text-[11px] font-medium ' +
(compact ? '' : 'backdrop-blur') (compact ? '' : '')
} }
> >
{SUPPORTED_LOCALES.map((locale) => { {SUPPORTED_LOCALES.map((locale) => {
@@ -30,10 +30,10 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
if (!active) void changeLocale(locale); if (!active) void changeLocale(locale);
}} }}
className={ className={
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' + 'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40' ? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'text-neutral-400 hover:text-neutral-200') : 'text-fg-muted hover:text-fg')
} }
> >
{LABELS[locale]} {LABELS[locale]}
@@ -16,7 +16,7 @@ interface Props {
} }
const MENU_W = 240; const MENU_W = 240;
const MENU_H = 84; const MENU_H = 96;
export function ParticipantVolumeMenu({ export function ParticipantVolumeMenu({
userId, userId,
@@ -63,14 +63,20 @@ export function ParticipantVolumeMenu({
> >
<div className="mb-2 flex items-center justify-between gap-2 text-xs"> <div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span> <span className="truncate font-semibold text-fg">{displayName}</span>
<span className="tabular-nums text-fg-muted"> <span
className={
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
}
>
{Math.round(volume * 100)}% {Math.round(volume * 100)}%
</span> </span>
</div> </div>
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
mics — the amber count-up hints at that without a verbose warning. */}
<input <input
type="range" type="range"
min={0} min={0}
max={1} max={2}
step={0.01} step={0.01}
value={volume} value={volume}
onChange={(e) => { onChange={(e) => {
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
aria-label={'Lautstärke ' + displayName} aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent" className="w-full accent-accent"
/> />
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
<span>0%</span>
<span className="tabular-nums">100%</span>
<span>200%</span>
</div>
</div>, </div>,
document.body, document.body,
); );
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
import {
AvatarColorKey,
colorKeyFor,
} from './CallParticipantTile';
import { HeadphonesOffIcon, MicOffIcon, UsersIcon, XIcon } from './icons';
// Rows the popover knows how to render. Subset of InCallPanel's Tile so this
// component can be reused without the screen-share / video fields.
export interface ParticipantRow {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
deafened: boolean;
}
interface Props {
open: boolean;
rows: ParticipantRow[];
/** Set of userIds currently above the speaking-threshold. */
activeSpeakers: Set<string>;
onClose: () => void;
}
const AVATAR_TONES: Record<AvatarColorKey, string> = {
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
};
// Call-scoped participant list. Portal-mounted + fixed-positioned so it
// floats above whichever call layout the user is in (docked, focus, or
// fullscreen cinema). Mirrors the ParticipantVolumeMenu pattern for
// close-on-outside / close-on-Esc behaviour so both feel consistent.
export function ParticipantsPopover({ open, rows, activeSpeakers, onClose }: Props) {
const { t } = useTranslation(['app']);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-participants-popover]')) return;
// Clicks on the triggering button also bubble here; the button itself
// handles toggle, so we only close on genuine outside clicks. The
// trigger uses `data-participants-trigger` — ignore those.
if (target?.closest('[data-participants-trigger]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
data-participants-popover
role="dialog"
aria-label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
className="fixed bottom-20 right-5 z-[70] flex max-h-[60vh] w-[300px] flex-col overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-xl backdrop-blur-md"
>
<header className="flex items-center justify-between gap-2 border-b border-line px-3.5 py-2.5">
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
<UsersIcon className="h-4 w-4 text-fg-muted" />
<span>
{t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length}
</span>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">
{rows.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-fg-muted">
{t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })}
</p>
) : (
<ul className="flex flex-col gap-1">
{rows.map((row) => (
<li key={row.userId}>
<Row row={row} speaking={activeSpeakers.has(row.userId)} />
</li>
))}
</ul>
)}
</div>
</div>,
document.body,
);
}
function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
const key = colorKeyFor(row.userId);
const tone = AVATAR_TONES[key];
const letter = row.displayName.trim().charAt(0).toUpperCase() || '?';
const [volume, setVolume] = useState<number>(() =>
row.self ? 1 : getParticipantVolume(row.userId),
);
useEffect(() => {
if (row.self) return;
return subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(row.userId));
});
}, [row.self, row.userId]);
return (
<div className="flex flex-col gap-1.5 rounded-lg px-2 py-1.5 hover:bg-surface-3/60">
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
{row.avatarUrl ? (
<img
src={row.avatarUrl}
alt=""
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<span
className={
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ' +
tone
}
>
{letter}
</span>
)}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute -inset-0.5 rounded-full border-2 border-emerald-500 dark:border-emerald-400"
/>
)}
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
{row.displayName}
{row.self && <span className="ml-1 text-fg-muted">(du)</span>}
</div>
<div className="flex shrink-0 items-center gap-1">
{row.muted && (
<span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" />
</span>
)}
{row.deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
{!row.self && (
<div className="flex items-center gap-2 pl-10 text-[11px] text-fg-muted">
<input
type="range"
min={0}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(row.userId, v);
}}
aria-label={'Lautstärke ' + row.displayName}
className="flex-1 accent-accent"
/>
<span
className={
'w-10 text-right tabular-nums ' +
(volume > 1 ? 'text-amber-500' : '')
}
>
{Math.round(volume * 100)}%
</span>
</div>
)}
</div>
);
}
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import { import {
clearIncomingRingtone, clearIncomingRingtone,
getIncomingRingtone, getIncomingRingtone,
@@ -30,6 +35,10 @@ export function RingtoneSettings({ disabled = false }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [volume, setVolume] = useState<number>(() => getAudioSettings().ringtoneVolume);
// Subscribe so cross-tab / in-call slider moves stay in sync here too.
useEffect(() => subscribeAudioSettings((s) => setVolume(s.ringtoneVolume)), []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -82,7 +91,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
if (code === 'ringtone_too_large') { if (code === 'ringtone_too_large') {
setError( setError(
t('app:settings.ringtone_error_too_large', { t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).', defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB, max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}), }),
); );
@@ -128,7 +137,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
const url = URL.createObjectURL(current.blob); const url = URL.createObjectURL(current.blob);
const el = new Audio(url); const el = new Audio(url);
el.loop = false; el.loop = false;
el.volume = 0.85; el.volume = volume;
el.onended = () => stopPreview(); el.onended = () => stopPreview();
el.onerror = () => { el.onerror = () => {
setError( setError(
@@ -233,10 +242,40 @@ export function RingtoneSettings({ disabled = false }: Props) {
</p> </p>
)} )}
<div className="flex items-center gap-3">
<label
htmlFor="ringtone-volume"
className="shrink-0 text-xs font-medium text-fg-muted"
>
{t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
</label>
<input
id="ringtone-volume"
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
updateAudioSettings({ ringtoneVolume: v });
// Apply to the currently-playing preview so the user hears the
// slider effect immediately while dragging.
if (previewRef.current) previewRef.current.volume = v;
}}
aria-label={t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
className="flex-1 accent-accent"
/>
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<p className="text-[11px] text-fg-muted"> <p className="text-[11px] text-fg-muted">
{t('app:settings.ringtone_hint', { {t('app:settings.ringtone_hint', {
defaultValue: defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.', 'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})} })}
</p> </p>
</div> </div>
@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import {
getScreenShareVolume,
setScreenShareVolume,
subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
interface Props {
/** Participant whose screen share the user right-clicked. */
userId: string;
displayName: string;
/** Whether the share has an audio track published. Controls whether the
* volume / mute rows render — without audio those would be no-ops. */
hasAudio: boolean;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 260;
const MENU_H_WITH_AUDIO = 200;
const MENU_H_NO_AUDIO = 96;
// Context menu surfaced on right-click of a remote screen share. Mirrors
// Discord's stream menu: volume slider, audio mute toggle (independent of
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
// which both un-subscribes locally and dismisses the tile from the grid.
export function ScreenShareContextMenu({
userId,
displayName,
hasAudio,
x,
y,
onClose,
}: Props) {
const { t } = useTranslation(['app']);
const {
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
} = useCall();
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
useEffect(
() =>
subscribeScreenShareVolumes(() => {
setVolume(getScreenShareVolume(userId));
}),
[userId],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-share-menu]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const muted = screenShareAudioMutedIds.has(userId);
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
return createPortal(
<div
data-share-menu
role="menu"
aria-label={t('app:call.share_menu_title', {
defaultValue: 'Bildschirmfreigabe von {{name}}',
name: displayName,
})}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
>
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<span className="truncate">
{t('app:call.share_menu_owner', {
defaultValue: 'Bildschirmfreigabe · {{name}}',
name: displayName,
})}
</span>
</header>
{hasAudio && (
<>
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-fg">
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
</span>
<span
className={
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
}
>
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setScreenShareVolume(userId, v);
}}
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[10px] text-fg-muted">
<span>0%</span>
<span>100%</span>
<span>200%</span>
</div>
</div>
<button
type="button"
onClick={() => setScreenShareAudioMuted(userId, !muted)}
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
>
{muted ? (
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
) : (
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
)}
<span className="flex-1">
{muted
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
</span>
</button>
</>
)}
<button
type="button"
onClick={() => {
dismissShare(userId);
onClose();
}}
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
>
<PhoneOffIcon className="h-4 w-4" />
<span>
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
</span>
</button>
</div>,
document.body,
);
}
@@ -1,262 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
// Persist the audio choice alongside the other picker prefs so the
// upstream startScreenShare picks it up on its settings read.
updateScreenShareSettings({ includeSystemAudio: includeAudio });
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
<div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-fg">
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{t('app:call.share_system_audio_hint', {
defaultValue:
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
})}
</span>
</span>
</label>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</p>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -2,7 +2,7 @@ import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext'; import { type RemoteScreenShare, useCall } from '../context/CallContext';
import { MonitorShareIcon } from './icons'; import { MonitorShareIcon } from './icons';
interface ScreenShareViewerProps { interface ScreenShareViewerProps {
@@ -12,12 +12,21 @@ interface ScreenShareViewerProps {
} }
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle. // Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
// Lifted out of the old InCallPanel so the new CallDock stays lean. // The watch-state lives in CallContext (not local useState) so it survives
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) { // layout-mode changes (grid → focus → fullscreen) without resetting. Same
// reason the ScreenShareAudio mute follows this state — see attachTrack.
// Right-click handling happens one level up in TileRender — the wrapping
// div catches the event before it reaches the viewer's inner content.
export function ScreenShareViewer({
share,
avatarUrl,
displayName,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false); const { watchingShareUserIds, watchShare } = useCall();
const watching = watchingShareUserIds.has(share.participantId);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; const letter = displayName.trim().charAt(0).toUpperCase() || '?';
@@ -68,31 +77,15 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
})} })}
</span> </span>
{watching && ( {watching && (
<> <button
<button type="button"
type="button" onClick={toggleFullscreen}
onClick={toggleFullscreen} aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })} title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })} className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40" >
> <FullscreenIcon className="h-3.5 w-3.5" />
<FullscreenIcon className="h-3.5 w-3.5" /> </button>
</button>
<button
type="button"
onClick={() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen();
}
setWatching(false);
}}
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
</button>
</>
)} )}
</div> </div>
@@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setWatching(true)} onClick={() => watchShare(share.participantId)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })} aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none" className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }} style={{ aspectRatio: '16 / 9' }}
@@ -0,0 +1,413 @@
import { memo, startTransition, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
captureScreenSourceThumbnailBytes,
listScreenSources,
type ScreenSource,
} from '../lib/screenSources';
import { MonitorShareIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
/** Parent handles the actual share start. `sourceId` is null when the user
* clicks "Teilen" without picking a specific source — fallback to the
* OS-level getDisplayMedia picker. */
onStart: (opts: {
sourceId: string | null;
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
includeAudio: boolean;
}) => Promise<void>;
}
// Discord-style picker. Replaces the old form-field dialog with a thumbnail
// grid sourced from the Rust `enumerate_screen_sources` command. Clicking a
// thumbnail stashes its Chromium-format id; the parent then attempts a
// `chromeMediaSourceId`-constrained getUserMedia call. If WebView2 ignores
// the constraint (it may), the fallback OS picker still runs — but at least
// the user already saw + chose from a real preview first.
export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [sources, setSources] = useState<ScreenSource[] | null>(null);
// Thumbnails are kept in a separate state from the source list so an
// arriving thumbnail never creates a new `ScreenSource` object for
// unrelated cards — memo compares `thumbnailUrl` by string identity,
// so only the one card whose URL changes rerenders.
const [thumbnailUrls, setThumbnailUrls] = useState<Record<string, string>>({});
// All blob URLs we've handed out this session. Revoked on picker close
// so the native buffers they point at don't leak across opens.
const blobUrlsRef = useRef<string[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Two-phase load: (1) fast list returns names + placeholders so the grid
// paints instantly, (2) capture thumbnails in a bounded worker-pool
// using the binary-IPC variant. Arriving bytes are wrapped in a Blob
// and exposed via URL.createObjectURL — no base64 on either side,
// which is the single biggest main-thread win compared to the old
// JSON-of-base64 flow. Combined with rAF-batched state updates, the
// picker stays responsive even on 20+ source enumerations.
useEffect(() => {
if (!open) {
// Revoke blob URLs created during the last session so native
// buffers don't linger after close.
for (const url of blobUrlsRef.current) URL.revokeObjectURL(url);
blobUrlsRef.current = [];
setSources(null);
setThumbnailUrls({});
setSelectedId(null);
setError(null);
return;
}
let cancelled = false;
// Coalesce thumbnail arrivals within a single animation frame into
// one setState — cuts re-renders from O(N) to O(frames) during the
// initial fan-in and prevents consecutive 10-30 ms long tasks from
// stacking in one frame.
//
// Previously `batch` was aliased to `pendingUrls` and then we cleared
// pendingUrls via `delete` — which emptied batch too (same reference)
// and every flush ended up spreading nothing into the state. Clone
// first, then clear, so the batch keeps its entries.
let pendingUrls: Record<string, string> = {};
let rafScheduled = false;
const flush = () => {
rafScheduled = false;
const batch = pendingUrls;
if (Object.keys(batch).length === 0) return;
pendingUrls = {};
startTransition(() => {
setThumbnailUrls((prev) => ({ ...prev, ...batch }));
});
};
const queueUrl = (id: string, url: string) => {
pendingUrls[id] = url;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(flush);
}
};
// Concurrency 2: Windows GDI BitBlt / PrintWindow contends for the
// desktop compositor, so 4+ parallel captures stutter the whole Tauri
// window. 2 in parallel keeps the compositor breathing.
const CONCURRENCY = 2;
void (async () => {
const list = await listScreenSources();
if (cancelled) return;
setSources(list);
const queue = [...list];
const pickOne = (src: typeof list[number]) => {
void (async () => {
const blob = await captureScreenSourceThumbnailBytes(src.id);
if (cancelled) {
// Edge case: picker closed while this request was in flight.
// blob may still exist; nothing holds a URL to it, so it GCs.
return;
}
if (blob) {
const url = URL.createObjectURL(blob);
blobUrlsRef.current.push(url);
queueUrl(src.id, url);
}
const nextSrc = queue.shift();
if (nextSrc) pickOne(nextSrc);
})();
};
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
const s = queue.shift();
if (s) pickOne(s);
}
})();
return () => {
cancelled = true;
};
}, [open]);
if (!open) return null;
const screens = sources?.filter((s) => s.kind === 'screen') ?? [];
const windows = sources?.filter((s) => s.kind === 'window') ?? [];
const hasAny = (sources?.length ?? 0) > 0;
async function handleStart(): Promise<void> {
setBusy(true);
setError(null);
try {
// Persist user's audio + preset choice so subsequent shares start with
// the same prefs when they skip the picker. The picker itself stays
// as the entry for future starts (right-click on share button also
// opens it — see InCallPanel wiring).
updateScreenShareSettings({ preset, includeSystemAudio: includeAudio });
// Infer a displaySurface hint from the selection so the fallback OS
// picker jumps to the right tab when our direct-publish path is
// rejected by WebView2.
const selected = sources?.find((s) => s.id === selectedId) ?? null;
const hint: DisplaySurfaceHint =
selected?.kind === 'screen'
? 'monitor'
: selected?.kind === 'window'
? 'window'
: null;
await onStart({
sourceId: selected?.id ?? null,
preset,
displaySurface: hint,
framerate: null,
includeAudio,
});
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[88vh] w-full max-w-[860px] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto">
{sources === null ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4" />
<span>
{t('app:call.share_loading', { defaultValue: 'Lade Quellen…' })}
</span>
</div>
) : !hasAny ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center text-sm text-fg-muted">
<MonitorShareIcon className="h-6 w-6 opacity-60" />
<span>
{t('app:call.share_no_sources', {
defaultValue:
'Keine Quellen-Previews verfügbar. Klick auf „Teilen" öffnet den System-Picker.',
})}
</span>
</div>
) : (
<div className="space-y-5 p-5">
{screens.length > 0 && (
<SourceSection
title={t('app:call.share_screens', { defaultValue: 'Bildschirme' })}
sources={screens}
thumbnailUrls={thumbnailUrls}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
{windows.length > 0 && (
<SourceSection
title={t('app:call.share_windows', { defaultValue: 'Fenster' })}
sources={windows}
thumbnailUrls={thumbnailUrls}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</div>
)}
</div>
<footer className="flex flex-col gap-3 border-t border-line bg-surface-2 px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
<span className="font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</span>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-xs text-fg focus:border-accent focus:outline-none"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="accent-accent"
/>
<span>
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
</label>
</div>
{error && (
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{selectedId
? t('app:call.share_start', { defaultValue: 'Teilen' })
: t('app:call.share_pick_system', {
defaultValue: 'Ohne Auswahl weiter',
})}
</span>
</button>
</div>
</footer>
</div>
</div>
);
}
function SourceSection({
title,
sources,
thumbnailUrls,
selectedId,
onSelect,
}: {
title: string;
sources: ScreenSource[];
thumbnailUrls: Record<string, string>;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<section>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{title}
</h4>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
thumbnailUrl={thumbnailUrls[src.id] ?? null}
selected={selectedId === src.id}
onSelect={onSelect}
/>
))}
</div>
</section>
);
}
// Memoized so a thumbnail arriving for card B doesn't re-render card A.
// Keeps re-render work proportional to the number of updates instead of
// "whole grid on every update" — which was the main reason scrolling felt
// frozen during the initial thumbnail fan-in.
//
// The parent passes `onSelect(id)` rather than an inline `onClick`-arrow
// so the callback reference stays stable across renders; otherwise
// React.memo would always see a fresh function prop and re-render every
// card on every parent update.
const SourceCard = memo(function SourceCard({
source,
thumbnailUrl,
selected,
onSelect,
}: {
source: ScreenSource;
thumbnailUrl: string | null;
selected: boolean;
onSelect: (id: string) => void;
}) {
return (
<button
type="button"
onClick={() => onSelect(source.id)}
aria-pressed={selected}
title={source.name}
className={
'group flex cursor-pointer flex-col overflow-hidden rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(selected
? 'border-accent ring-2 ring-accent/30'
: 'border-line hover:border-accent/70')
}
>
<div className="relative aspect-video w-full overflow-hidden bg-black">
{thumbnailUrl ? (
// decoding="async" keeps image decode off the main-thread paint
// step; loading="lazy" means cards outside the viewport don't
// ask the browser to decode until the user scrolls to them. The
// URL is a blob: URL backed by the ArrayBuffer Rust sent over
// IPC — no base64 decode, no data-URL parse, just direct bytes
// into the decoder.
<img
src={thumbnailUrl}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-contain transition group-hover:brightness-110"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-surface-2 to-surface-3 text-fg-muted">
<MonitorShareIcon className="h-6 w-6 opacity-50" />
</div>
)}
</div>
<div className="truncate px-2.5 py-1.5 text-left text-xs font-medium text-fg">
{source.name}
</div>
</button>
);
});
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -28,6 +28,12 @@ export interface AudioSettings {
// @livekit/track-processors + its MediaPipe selfie-segmentation model // @livekit/track-processors + its MediaPipe selfie-segmentation model
// (~1.5MB) which downloads on first activation. // (~1.5MB) which downloads on first activation.
videoBackgroundBlur: boolean; videoBackgroundBlur: boolean;
// Ringtone volume for both the generated oscillator fallback and the
// custom incoming-call audio file. 0..1; applied on top of the base
// oscillator gain so the fallback stays audible at 100% without being
// harsh at 25%. Separate from any system / call audio volume so users
// can have loud rings + soft in-call audio.
ringtoneVolume: number;
} }
const DEFAULTS: AudioSettings = { const DEFAULTS: AudioSettings = {
@@ -35,8 +41,12 @@ const DEFAULTS: AudioSettings = {
inputDeviceId: null, inputDeviceId: null,
outputDeviceId: null, outputDeviceId: null,
voiceThreshold: 0.03, voiceThreshold: 0.03,
noiseSuppression: true, // Off by default — browser-native NS colours voice audibly on some
// mics and is a frequent "why does my voice sound weird" report.
// Users who want it enable it explicitly in Settings → Sprache.
noiseSuppression: false,
videoBackgroundBlur: false, videoBackgroundBlur: false,
ringtoneVolume: 0.9,
}; };
export interface AudioQualityParams { export interface AudioQualityParams {
@@ -118,6 +128,13 @@ function read(): AudioSettings {
typeof parsed.videoBackgroundBlur === 'boolean' typeof parsed.videoBackgroundBlur === 'boolean'
? parsed.videoBackgroundBlur ? parsed.videoBackgroundBlur
: DEFAULTS.videoBackgroundBlur, : DEFAULTS.videoBackgroundBlur,
ringtoneVolume:
typeof parsed.ringtoneVolume === 'number' &&
Number.isFinite(parsed.ringtoneVolume) &&
parsed.ringtoneVolume >= 0 &&
parsed.ringtoneVolume <= 1
? parsed.ringtoneVolume
: DEFAULTS.ringtoneVolume,
}; };
return cached; return cached;
} catch { } catch {
+32
View File
@@ -48,6 +48,38 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
} }
} }
// Press-only global shortcut (for toggles like Mute/Deafen). Accepts an
// already-formatted accelerator string (e.g. "CommandOrControl+Shift+M")
// since these bindings may include modifier chords — the KeyboardEvent.code
// variant used by PTT can't express that.
export async function registerGlobalShortcutPress(
shortcut: string,
onPress: () => void,
): Promise<boolean> {
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
});
return true;
} catch (err: unknown) {
console.warn('registerGlobalShortcutPress failed', { shortcut, err });
return false;
}
}
export async function unregisterGlobalShortcut(shortcut: string): Promise<void> {
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
} catch (err: unknown) {
console.warn('unregisterGlobalShortcut failed', { shortcut, err });
}
}
// Detects whether we're running under Tauri. When running in a pure web // Detects whether we're running under Tauri. When running in a pure web
// preview (vite dev in a browser without Tauri), importing the plugin still // preview (vite dev in a browser without Tauri), importing the plugin still
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't // works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
+13 -2
View File
@@ -30,9 +30,14 @@ function load(): VolumeMap {
} }
} }
// Matches Discord's slider range — up to 200% via a WebAudio GainNode in
// remoteAudioPipelines (HTMLMediaElement.volume caps at 1.0 on its own).
const MAX_VOLUME = 2;
function clamp(v: number): number { function clamp(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0; if (v < 0) return 0;
if (v > 1) return 1; if (v > MAX_VOLUME) return MAX_VOLUME;
return v; return v;
} }
@@ -73,12 +78,18 @@ export function subscribeParticipantVolumes(fn: Listener): () => void {
// Apply a volume to any audio elements already attached for this user. // Apply a volume to any audio elements already attached for this user.
// Attached elements are tagged with `data-participant` in attachTrack. // Attached elements are tagged with `data-participant` in attachTrack.
// HTMLMediaElement.volume is hard-clamped to [0, 1] — anything above 1
// throws IndexSizeError. Values above 1 are only meaningful on the
// WebAudio path (remoteAudioPipelines' GainNode handles them); on the
// plain-element fallback path we clip at 1.0 so the user just hears the
// loudest level the element supports rather than an exception.
function applyToAttachedElements(userId: string, volume: number): void { function applyToAttachedElements(userId: string, volume: number): void {
const elVolume = Math.min(1, Math.max(0, volume));
const nodes = document.querySelectorAll<HTMLAudioElement>( const nodes = document.querySelectorAll<HTMLAudioElement>(
'audio[data-participant="' + cssEscape(userId) + '"]', 'audio[data-participant="' + cssEscape(userId) + '"]',
); );
nodes.forEach((el) => { nodes.forEach((el) => {
el.volume = volume; el.volume = elVolume;
}); });
} }
@@ -0,0 +1,151 @@
// Central registry of Web-Audio pipelines for every remote audio track we're
// playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a
// user boost a quiet peer past 100% we need an explicit GainNode in the
// output chain. Once a track is on the WebAudio path, `audio.muted` / volume
// no longer drive output (the MediaElementSource diverts samples through the
// graph), so all gating — deafen, watch-state, manual mute, per-user volume —
// is collapsed into a single effective-gain value per pipeline.
//
// The registry is a plain module-level Map. Attach/detach lifecycle is owned
// by CallContext's attachTrack/detachTrack helpers; gain recomputation is
// also triggered from CallContext when any state the formula depends on
// changes.
export type RemoteTrackSource = 'microphone' | 'screenshare';
export interface RemoteAudioPipeline {
/** LiveKit track sid — stable identifier for this track's lifetime. */
trackSid: string;
participantId: string;
trackSource: RemoteTrackSource;
audio: HTMLAudioElement;
ctx: AudioContext;
source: MediaElementAudioSourceNode;
gain: GainNode;
}
const pipelines = new Map<string, RemoteAudioPipeline>();
// Build the WebAudio chain for an already-attached HTMLAudioElement. Returns
// null when WebAudio isn't available (older browsers) — callers should fall
// back to `audio.volume` in that case.
export function createPipeline(
audio: HTMLAudioElement,
info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource },
): RemoteAudioPipeline | null {
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return null;
try {
const ctx = new AudioCtx();
const source = ctx.createMediaElementSource(audio);
const gain = ctx.createGain();
// Start silent; the caller (CallContext) applies the correct effective
// gain immediately after registering via `setPipelineGain`.
gain.gain.value = 0;
source.connect(gain);
gain.connect(ctx.destination);
// createMediaElementSource diverts the element's direct output through
// the audio graph. Muting the element is then a double-guard — if the
// diversion ever fails (older WebKit), the element stays silent instead
// of bypassing the gain chain entirely.
audio.muted = true;
const pipeline: RemoteAudioPipeline = {
trackSid: info.trackSid,
participantId: info.participantId,
trackSource: info.trackSource,
audio,
ctx,
source,
gain,
};
pipelines.set(info.trackSid, pipeline);
return pipeline;
} catch (err: unknown) {
console.warn('createPipeline failed', err);
return null;
}
}
export function destroyPipeline(trackSid: string): void {
const p = pipelines.get(trackSid);
if (!p) return;
pipelines.delete(trackSid);
try {
p.source.disconnect();
} catch {
/* ignore */
}
try {
p.gain.disconnect();
} catch {
/* ignore */
}
void p.ctx.close().catch(() => {
/* ignore */
});
}
export function allPipelines(): IterableIterator<RemoteAudioPipeline> {
return pipelines.values();
}
export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined {
return pipelines.get(trackSid);
}
export function pipelinesFor(
participantId: string,
source?: RemoteTrackSource,
): RemoteAudioPipeline[] {
const out: RemoteAudioPipeline[] = [];
for (const p of pipelines.values()) {
if (p.participantId !== participantId) continue;
if (source && p.trackSource !== source) continue;
out.push(p);
}
return out;
}
// Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so
// rapid slider drags stay smooth. ctx.currentTime is the right anchor —
// setValueAtTime jumps abruptly.
export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void {
const v = clampGain(value);
try {
pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02);
} catch {
// Some contexts in a closed state throw — just direct-assign as a
// fallback; worst case the next valid call smooths it out.
pipeline.gain.gain.value = v;
}
}
export function clampGain(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
// 2.0 matches Discord's upper bound (200%). Going higher invites clipping
// since the source is already peaking at 1.0 for most mics.
if (v > 2) return 2;
return v;
}
// Route every pipeline's AudioContext to the given sink. Feature-detects
// `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the
// default sink, which is the behaviour we had before the WebAudio refactor
// so this is a strict improvement rather than a regression.
export async function setAllPipelinesSinkId(deviceId: string): Promise<void> {
const maybeId = deviceId.length === 0 ? 'default' : deviceId;
for (const p of pipelines.values()) {
const ctxAny = p.ctx as unknown as {
setSinkId?: (id: string) => Promise<void>;
};
if (typeof ctxAny.setSinkId !== 'function') continue;
try {
await ctxAny.setSinkId(maybeId);
} catch (err: unknown) {
console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
}
}
}
+36 -10
View File
@@ -7,6 +7,7 @@
// back to the generated oscillator pattern so ringing never misses an // back to the generated oscillator pattern so ringing never misses an
// incoming call due to an IO failure. // incoming call due to an IO failure.
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
import { getIncomingRingtone } from './ringtoneStorage'; import { getIncomingRingtone } from './ringtoneStorage';
type Pattern = 'outgoing' | 'incoming'; type Pattern = 'outgoing' | 'incoming';
@@ -21,6 +22,15 @@ class Ringtone {
private customUrl: string | null = null; private customUrl: string | null = null;
// Sequence token to ignore slow IO completing after user changed state. // Sequence token to ignore slow IO completing after user changed state.
private startSeq = 0; private startSeq = 0;
// Live-subscribe so settings-slider changes reflect while the ringtone
// is playing (user can hear the effect of their slider immediately).
private unsubVolume: (() => void) | null = null;
private get volume(): number {
const v = getAudioSettings().ringtoneVolume;
if (!Number.isFinite(v)) return 0.9;
return Math.min(1, Math.max(0, v));
}
start(pattern: Pattern): void { start(pattern: Pattern): void {
if (this.pattern === pattern) return; // already playing this pattern if (this.pattern === pattern) return; // already playing this pattern
@@ -28,6 +38,12 @@ class Ringtone {
this.pattern = pattern; this.pattern = pattern;
const seq = ++this.startSeq; const seq = ++this.startSeq;
// Track live slider moves so the user can dial in the volume while a
// call is ringing and hear the change immediately.
this.unsubVolume = subscribeAudioSettings(() => {
if (this.audioEl) this.audioEl.volume = this.volume;
});
if (pattern === 'incoming') { if (pattern === 'incoming') {
// Kick off oscillator immediately so we never miss ringing feedback // Kick off oscillator immediately so we never miss ringing feedback
// while the custom file (if any) loads asynchronously. Once the blob // while the custom file (if any) loads asynchronously. Once the blob
@@ -44,6 +60,10 @@ class Ringtone {
this.stopOscillator(); this.stopOscillator();
this.stopCustom(); this.stopCustom();
this.pattern = null; this.pattern = null;
if (this.unsubVolume) {
this.unsubVolume();
this.unsubVolume = null;
}
} }
// --- Custom file path (incoming only) ---------------------------------- // --- Custom file path (incoming only) ----------------------------------
@@ -61,7 +81,7 @@ class Ringtone {
const url = URL.createObjectURL(stored.blob); const url = URL.createObjectURL(stored.blob);
const el = new Audio(url); const el = new Audio(url);
el.loop = true; el.loop = true;
el.volume = 0.85; el.volume = this.volume;
// Chrome/WKWebView autoplay policy: muted playback is always allowed, // Chrome/WKWebView autoplay policy: muted playback is always allowed,
// but ringtones must be audible, so play() may reject the first time // but ringtones must be audible, so play() may reject the first time
// before the user interacted. If it rejects, we keep the oscillator. // before the user interacted. If it rejects, we keep the oscillator.
@@ -132,25 +152,31 @@ class Ringtone {
osc.connect(g); osc.connect(g);
g.connect(ctx.destination); g.connect(ctx.destination);
const t0 = ctx.currentTime + delaySec; const t0 = ctx.currentTime + delaySec;
// Volume slider multiplies the base gain so the fallback tone tracks
// the user's preference. A flat user-setting of 0 keeps the pattern
// running visually (oscillator nodes alive) but inaudible.
const effectiveGain = gain * this.volume;
g.gain.setValueAtTime(0, t0); g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02); g.gain.linearRampToValueAtTime(effectiveGain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec); g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
osc.start(t0); osc.start(t0);
osc.stop(t0 + durationSec + 0.02); osc.stop(t0 + durationSec + 0.02);
} }
private playOutgoing(): void { private playOutgoing(): void {
// Soft calling tone — single warm note. // Soft calling tone — single warm note. Slightly bumped from 0.14 so
this.beep(440, 0.4, 0, 0.14); // it's audible on laptop speakers without blasting.
this.beep(440, 0.4, 0.6, 0.14); this.beep(440, 0.4, 0, 0.22);
this.beep(440, 0.4, 0.6, 0.22);
} }
private playIncoming(): void { private playIncoming(): void {
// Classic double-ring "ring ring". // Classic double-ring "ring ring". Bumped from 0.22 → 0.4 so it's
this.beep(880, 0.18, 0, 0.22); // unmissable through music / background noise.
this.beep(660, 0.18, 0.22, 0.22); this.beep(880, 0.18, 0, 0.4);
this.beep(880, 0.18, 0.6, 0.22); this.beep(660, 0.18, 0.22, 0.4);
this.beep(660, 0.18, 0.82, 0.22); this.beep(880, 0.18, 0.6, 0.4);
this.beep(660, 0.18, 0.82, 0.4);
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export interface StoredRingtone {
updatedAt: number; updatedAt: number;
} }
export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap export const MAX_RINGTONE_BYTES = 8 * 1024 * 1024; // 8 MB cap
export const SUPPORTED_RINGTONE_MIMES = [ export const SUPPORTED_RINGTONE_MIMES = [
'audio/mpeg', 'audio/mpeg',
+260
View File
@@ -0,0 +1,260 @@
// Frontend side of the native system-audio pipeline. Pairs with the Rust
// `screen_audio` module: it opens a Tauri Channel, receives interleaved
// f32 stereo samples at 48kHz (base64-encoded), and surfaces them as a
// real `MediaStream` that LiveKit can publish as a `ScreenShareAudio`
// track. An AudioWorklet does the heavy lifting so the render thread is
// never the bottleneck — the main thread just pushes decoded samples
// across a port; the worklet copies them into its output buffer which
// feeds a `MediaStreamDestination`.
//
// Windows-only right now. On other platforms `startSystemAudioCapture`
// throws `SystemAudioUnavailable` and the caller is expected to fall
// back to the browser's getDisplayMedia path.
import { isTauriRuntime } from './globalShortcut';
export interface SystemAudioHandle {
/** Rust-side capture id. Pass to the Rust stop command via `stop()`. */
captureId: number;
/** MediaStream carrying a single audio track at 48kHz stereo. */
stream: MediaStream;
/** Teardown — stops the Rust thread, closes the AudioContext, ends the
* MediaStreamDestination track. Idempotent. */
stop: () => Promise<void>;
}
/** Thrown when the platform can't deliver native system-audio (non-Tauri
* runtime, non-Windows host, WebAudio unavailable, COM init failure). */
export class SystemAudioUnavailable extends Error {
constructor(reason: string) {
super('system audio unavailable: ' + reason);
this.name = 'SystemAudioUnavailable';
}
}
interface AudioFramePayload {
captureId: number;
sampleRate: number;
channels: number;
samplesBase64: string;
}
// AudioWorklet source embedded as a string. The worklet keeps a pair of
// ring buffers (one per channel) that the main thread appends to as
// samples arrive. `process()` drains the ring buffers into the output
// blocks; an underrun emits silence instead of propagating the stall
// upwards (a glitch is better than a freeze for LiveKit's Opus encoder).
//
// The worklet runs at AudioContext sample rate, which we pin to 48kHz via
// the AudioContext constructor. That matches what the Rust side already
// resamples to, so no further rate conversion is needed here.
const WORKLET_SOURCE = `
class LoopbackAudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
// Ring buffer sized for latency, not for "never drop". 300ms hard cap,
// 80ms target — we aim for ~one WASAPI packet of headroom above the
// render quantum and drop excess whenever the producer gets ahead.
// Keeping the target small is the difference between "feels live" and
// "laggy" for screen-share audio.
this.bufferSize = 48000 * 0.3 | 0;
this.targetFrames = 48000 * 0.08 | 0;
this.bufL = new Float32Array(this.bufferSize);
this.bufR = new Float32Array(this.bufferSize);
this.writePos = 0;
this.readPos = 0;
this.available = 0;
this.port.onmessage = (e) => {
const { left, right } = e.data;
const len = left.length;
for (let i = 0; i < len; i++) {
this.bufL[this.writePos] = left[i];
this.bufR[this.writePos] = right[i];
this.writePos = (this.writePos + 1) % this.bufferSize;
if (this.available < this.bufferSize) {
this.available++;
} else {
// Buffer full — advance the read cursor to keep writing.
this.readPos = (this.readPos + 1) % this.bufferSize;
}
}
// Hard cap: if we're this far behind the producer, skip ahead to
// the target latency instead of playing out minutes of stale audio.
// Happens on: AudioContext resume after suspend, tab throttle
// recovery, any hiccup that left samples piling up.
if (this.available > this.targetFrames * 3) {
const drop = this.available - this.targetFrames;
this.readPos = (this.readPos + drop) % this.bufferSize;
this.available -= drop;
}
};
}
process(_inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const out0 = output[0];
const out1 = output[1] || output[0];
const n = out0.length;
for (let i = 0; i < n; i++) {
if (this.available > 0) {
out0[i] = this.bufL[this.readPos];
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
this.readPos = (this.readPos + 1) % this.bufferSize;
this.available--;
} else {
out0[i] = 0;
if (out1 !== out0) out1[i] = 0;
}
}
return true;
}
}
registerProcessor('screen-audio-loopback', LoopbackAudioProcessor);
`;
let workletModuleUrl: string | null = null;
function getWorkletModuleUrl(): string {
if (workletModuleUrl) return workletModuleUrl;
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
workletModuleUrl = URL.createObjectURL(blob);
return workletModuleUrl;
}
export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
if (!isTauriRuntime()) {
throw new SystemAudioUnavailable('not a tauri runtime');
}
const AudioCtor: typeof AudioContext | undefined =
typeof window !== 'undefined'
? (window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext)
: undefined;
if (!AudioCtor) {
throw new SystemAudioUnavailable('WebAudio unavailable');
}
// Pin to 48kHz so the worklet's input rate matches the Rust-side
// output rate. If the OS forces a different rate the constructor
// throws on some browsers; we catch and surface as Unavailable so the
// caller can fall back.
let ctx: AudioContext;
try {
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
} catch (err: unknown) {
throw new SystemAudioUnavailable(
err instanceof Error ? err.message : String(err),
);
}
try {
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
} catch (err: unknown) {
await ctx.close().catch(() => undefined);
throw new SystemAudioUnavailable(
'audioWorklet load failed: ' +
(err instanceof Error ? err.message : String(err)),
);
}
const node = new AudioWorkletNode(ctx, 'screen-audio-loopback', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
const dest = ctx.createMediaStreamDestination();
node.connect(dest);
// Kick the AudioContext out of `suspended` before any samples arrive —
// the share is triggered from a user click so autoplay policy allows
// this, and an un-resumed context would buffer everything the Rust
// side produces until the context eventually runs, giving seconds of
// initial latency.
if (ctx.state !== 'running') {
try {
await ctx.resume();
} catch (err: unknown) {
console.warn('system-audio ctx.resume failed', err);
}
}
const { Channel, invoke } = await import('@tauri-apps/api/core');
const channel = new Channel<AudioFramePayload>();
channel.onmessage = (frame: AudioFramePayload) => {
const bytes = base64ToBytes(frame.samplesBase64);
// Re-view the bytes as f32 little-endian. The byteLength is always
// a multiple of 8 (f32 stereo pairs) — if not, drop the trailing
// partial frame rather than risk a truncation artifact.
const sampleCount = Math.floor(bytes.byteLength / 4);
if (sampleCount < 2) return;
const floats = new Float32Array(
bytes.buffer,
bytes.byteOffset,
sampleCount,
);
// Interleaved L/R → deinterleaved for the worklet. Copying out of
// the base64 view also ensures the Float32Arrays we postMessage are
// owned (the underlying buffer is about to be garbage-collected).
const frames = floats.length >> 1;
const left = new Float32Array(frames);
const right = new Float32Array(frames);
for (let i = 0; i < frames; i++) {
left[i] = floats[i * 2] ?? 0;
right[i] = floats[i * 2 + 1] ?? 0;
}
// Transfer the buffers so postMessage is zero-copy.
node.port.postMessage(
{ left, right },
[left.buffer, right.buffer],
);
};
let captureId: number;
try {
captureId = await invoke<number>('start_system_audio_capture', { channel });
} catch (err: unknown) {
node.disconnect();
await ctx.close().catch(() => undefined);
throw new SystemAudioUnavailable(
err instanceof Error ? err.message : String(err),
);
}
const stream = dest.stream;
let stopped = false;
const stop = async (): Promise<void> => {
if (stopped) return;
stopped = true;
try {
await invoke('stop_system_audio_capture', { captureId });
} catch (err: unknown) {
console.warn('stop_system_audio_capture failed', err);
}
try {
node.disconnect();
} catch {
/* already disconnected */
}
for (const track of stream.getTracks()) {
try {
track.stop();
} catch {
/* already stopped */
}
}
await ctx.close().catch(() => undefined);
};
return { captureId, stream, stop };
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}
return bytes;
}
+184
View File
@@ -0,0 +1,184 @@
// Frontend side of the native screen-capture pipeline. Starts a Rust-side
// capture thread via `start_screen_capture` and streams JPEG frames back
// through a Tauri Channel. Each frame is decoded into an ImageBitmap,
// drawn onto an offscreen canvas, and the canvas' captureStream() is
// returned as a MediaStream that LiveKit can publishTrack() directly —
// no OS/browser screen picker is involved.
//
// Video-only: system audio would require WASAPI / ScreenCaptureKit hooks
// that xcap doesn't provide. Callers that request shared audio must
// either fall back to the browser picker or accept video-without-audio.
import { isTauriRuntime } from './globalShortcut';
export interface NativeCaptureHandle {
/** Rust-side capture id. Pass to `stopNativeCapture` to tear down. */
captureId: number;
/** MediaStream fed by a canvas that's drawing each incoming frame. */
stream: MediaStream;
/** Cleanup — stops the Rust thread, closes channels, revokes the canvas
* stream. Idempotent. */
stop: () => Promise<void>;
}
interface FramePayload {
captureId: number;
width: number;
height: number;
jpegBase64: string;
}
/** Returned when the runtime can't support native capture (no Tauri, no
* WebAudio, source vanished between enumeration and start, etc.). The
* caller is expected to fall back to the browser's getDisplayMedia path. */
export class NativeCaptureUnavailable extends Error {
constructor(reason: string) {
super('native capture unavailable: ' + reason);
this.name = 'NativeCaptureUnavailable';
}
}
export async function startNativeCapture(opts: {
sourceId: string;
maxWidth: number;
maxHeight: number;
fps: number;
}): Promise<NativeCaptureHandle> {
if (!isTauriRuntime()) {
throw new NativeCaptureUnavailable('not a tauri runtime');
}
const canvas = document.createElement('canvas');
canvas.width = opts.maxWidth;
canvas.height = opts.maxHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new NativeCaptureUnavailable('canvas 2d context unavailable');
}
// Track whether we got the first frame so we can fail fast if Rust
// reports "found" but then produces no output (e.g. screen was locked).
let firstFrameResolved = false;
let firstFrameResolve!: () => void;
let firstFrameReject!: (err: Error) => void;
const firstFramePromise = new Promise<void>((resolve, reject) => {
firstFrameResolve = resolve;
firstFrameReject = reject;
});
const { Channel, invoke } = await import('@tauri-apps/api/core');
const channel = new Channel<FramePayload>();
// Latest-wins frame queue: if the JS side falls behind the Rust producer,
// we drop stale frames rather than queue them. Keeps memory flat and
// latency sensible for live screenshare.
let pendingFrame: FramePayload | null = null;
let decoding = false;
const drainQueue = async () => {
if (decoding) return;
decoding = true;
try {
while (pendingFrame) {
const frame = pendingFrame;
pendingFrame = null;
const bytes = base64ToBytes(frame.jpegBase64);
// Uint8Array's buffer type is `ArrayBufferLike` (could be a
// SharedArrayBuffer in theory); Blob wants plain ArrayBuffer.
// Pass the underlying buffer explicitly so the type narrows.
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err: unknown) {
console.warn('createImageBitmap failed', err);
continue;
}
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
canvas.width = bitmap.width;
canvas.height = bitmap.height;
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
if (!firstFrameResolved) {
firstFrameResolved = true;
firstFrameResolve();
}
}
} finally {
decoding = false;
}
};
channel.onmessage = (frame: FramePayload) => {
pendingFrame = frame;
void drainQueue();
};
let captureId: number;
try {
captureId = await invoke<number>('start_screen_capture', {
sourceId: opts.sourceId,
maxWidth: opts.maxWidth,
maxHeight: opts.maxHeight,
fps: opts.fps,
channel,
});
} catch (err: unknown) {
throw new NativeCaptureUnavailable(
err instanceof Error ? err.message : String(err),
);
}
// Bound the wait: the capture thread may fail silently on some sources
// (locked screens, protected windows). Fall back to getDisplayMedia in
// that case rather than hang the user.
const firstFrameTimeout = window.setTimeout(() => {
firstFrameReject(new Error('first frame timed out (3s)'));
}, 3000);
try {
await firstFramePromise;
} catch (err: unknown) {
window.clearTimeout(firstFrameTimeout);
try {
await invoke('stop_screen_capture', { captureId });
} catch {
/* ignore */
}
throw new NativeCaptureUnavailable(
err instanceof Error ? err.message : String(err),
);
}
window.clearTimeout(firstFrameTimeout);
const stream = canvas.captureStream(opts.fps);
let stopped = false;
const stop = async (): Promise<void> => {
if (stopped) return;
stopped = true;
try {
await invoke('stop_screen_capture', { captureId });
} catch (err: unknown) {
console.warn('stop_screen_capture failed', err);
}
for (const track of stream.getTracks()) {
try {
track.stop();
} catch {
/* already stopped */
}
}
};
return { captureId, stream, stop };
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}
return bytes;
}
@@ -0,0 +1,84 @@
// Session-only per-share audio volume. Mirror of `participantVolumes` but
// NOT persisted — when the user leaves the call or restarts the app, these
// reset to default. Intentional: the relevant trackSid is ephemeral anyway,
// and users don't expect screen-share volume to survive between sessions.
//
// Keys are participantIds (LiveKit identity). There's one screen-share per
// participant at a time in LiveKit, so keying by id keeps the API aligned
// with how the context menu surfaces the control ("Dennis's share").
const DEFAULT_VOLUME = 1;
// Matches Discord's slider range — up to 200% via the WebAudio GainNode in
// remoteAudioPipelines. HTMLMediaElement.volume only goes to 1.0, so the
// above-100% values are only meaningful on the WebAudio output path.
const MAX_VOLUME = 2;
type VolumeMap = Record<string, number>;
type Listener = (map: VolumeMap) => void;
let current: VolumeMap = {};
const listeners = new Set<Listener>();
function clamp(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
if (v < 0) return 0;
if (v > MAX_VOLUME) return MAX_VOLUME;
return v;
}
function notify(): void {
for (const fn of listeners) fn(current);
}
export function getScreenShareVolume(userId: string): number {
return current[userId] ?? DEFAULT_VOLUME;
}
export function setScreenShareVolume(userId: string, volume: number): void {
const next = clamp(volume);
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
current = { ...current, [userId]: next };
applyToAttachedElements(userId, next);
notify();
}
export function subscribeScreenShareVolumes(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
// Reset on call end — called from CallContext when CallState.idle triggers.
export function clearScreenShareVolumes(): void {
if (Object.keys(current).length === 0) return;
current = {};
notify();
}
// Live-apply to any <audio> element already attached for this share. The
// elements are tagged by attachTrack in CallContext with
// `data-participant="<identity>"` + `data-track-source="screenshare"` — the
// combined selector makes sure we don't retarget the mic audio for the same
// user (different track-source). HTMLMediaElement.volume caps at 1.0, so
// clip here — the WebAudio GainNode on the live pipeline handles values
// above 1.
function applyToAttachedElements(userId: string, volume: number): void {
const elVolume = Math.min(1, Math.max(0, volume));
const nodes = document.querySelectorAll<HTMLAudioElement>(
'audio[data-participant="' +
cssEscape(userId) +
'"][data-track-source="screenshare"]',
);
nodes.forEach((el) => {
el.volume = elVolume;
});
}
function cssEscape(v: string): string {
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
?.escape === 'function') {
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
}
return v.replace(/"/g, '\\"');
}
+150
View File
@@ -0,0 +1,150 @@
// Frontend wrapper for the Rust `enumerate_screen_sources` command. Falls
// back to an empty list outside the Tauri runtime so a browser-only dev
// build (pnpm vite:dev in Chrome without Tauri) degrades gracefully to
// "nothing to show" rather than throwing.
import { isTauriRuntime } from './globalShortcut';
export type ScreenSourceKind = 'screen' | 'window';
export interface ScreenSource {
/** Chromium-format source id ("screen:<id>:0" / "window:<hwnd>:0"). */
id: string;
name: string;
kind: ScreenSourceKind;
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
* Kept under `thumbnailPng` key for rollout stability — the server-side
* format switched from PNG to JPEG for payload size, but the field name
* preserves the wire contract during the transition. */
thumbnailPng: string | null;
width: number;
height: number;
}
// Fast, metadata-only list. The picker uses this first so names show up
// immediately; thumbnails stream in via captureScreenSourceThumbnail below.
export async function listScreenSources(): Promise<ScreenSource[]> {
if (!isTauriRuntime()) return [];
try {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<ScreenSource[]>('list_screen_sources');
return raw ?? [];
} catch (err: unknown) {
console.warn('list_screen_sources failed', err);
return [];
}
}
// Single-source thumbnail capture (legacy base64 variant). Callers should
// prefer `captureScreenSourceThumbnailBytes` below — it ships raw JPEG
// bytes over IPC so the main thread avoids both the base64 decode AND
// the JSON parse overhead of a long string result. Kept for fallback.
export async function captureScreenSourceThumbnail(
sourceId: string,
): Promise<string | null> {
if (!isTauriRuntime()) return null;
try {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<string | null>('capture_screen_source_thumbnail', {
sourceId,
});
return result ?? null;
} catch (err: unknown) {
console.warn('capture_screen_source_thumbnail failed', { sourceId, err });
return null;
}
}
// Thumbnail fetch with a two-tier fallback:
// 1. binary-IPC path (`..._bytes`) — ArrayBuffer over Tauri's raw channel
// 2. base64 path (legacy) — same command minus the ArrayBuffer wrapper
//
// The binary path can come through in several shapes depending on the
// Tauri / WebView2 version combo: a real ArrayBuffer, a Uint8Array, or
// occasionally a plain number[] when the response got re-serialised.
// We normalise all three into an ArrayBuffer before handing it to Blob.
// If the binary path returns nothing usable we retry once on the base64
// command — keeps thumbnails visible while the binary contract settles.
let warnedBinaryShape = false;
export async function captureScreenSourceThumbnailBytes(
sourceId: string,
): Promise<Blob | null> {
if (!isTauriRuntime()) return null;
const { invoke } = await import('@tauri-apps/api/core');
// ---- Tier 1: binary IPC ---------------------------------------------
try {
const result = await invoke<ArrayBuffer | Uint8Array | number[] | null>(
'capture_screen_source_thumbnail_bytes',
{ sourceId },
);
let bytes: Uint8Array | null = null;
if (result instanceof ArrayBuffer) {
bytes = new Uint8Array(result);
} else if (result instanceof Uint8Array) {
bytes = result;
} else if (Array.isArray(result) && result.length > 0) {
bytes = new Uint8Array(result);
} else if (result && typeof result === 'object') {
// One-time diagnostic so we can see the unexpected shape in the
// console if WebView2 de-serialises the Response body into a bag
// of properties instead of a transferable binary buffer.
if (!warnedBinaryShape) {
warnedBinaryShape = true;
console.warn(
'capture_screen_source_thumbnail_bytes: unexpected shape, falling back',
result,
);
}
}
if (bytes && bytes.byteLength > 0) {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer], { type: 'image/jpeg' });
}
} catch (err: unknown) {
console.warn('binary thumbnail path threw, trying base64 fallback', {
sourceId,
err,
});
}
// ---- Tier 2: base64 fallback ----------------------------------------
try {
const b64 = await invoke<string | null>('capture_screen_source_thumbnail', {
sourceId,
});
if (!b64) return null;
const bin = atob(b64);
const fallbackBytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) fallbackBytes[i] = bin.charCodeAt(i);
return new Blob([fallbackBytes.buffer], { type: 'image/jpeg' });
} catch (err: unknown) {
console.warn('base64 thumbnail fallback failed', { sourceId, err });
return null;
}
}
// Legacy single-shot variant. Captures everything serially on the Rust side
// before returning. Prefer listScreenSources + captureScreenSourceThumbnail
// for user-facing flows — they feel 510× more responsive in practice.
export async function enumerateScreenSources(): Promise<ScreenSource[]> {
if (!isTauriRuntime()) return [];
try {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<ScreenSource[]>('enumerate_screen_sources');
return raw ?? [];
} catch (err: unknown) {
console.warn('enumerate_screen_sources failed', err);
return [];
}
}
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
// thumbnail bytes never leave the component's render pass. Rust encodes
// JPEG now (smaller payload, faster decode); the mime type here must
// match or the <img> element silently fails to paint.
export function thumbnailDataUrl(src: ScreenSource): string | null {
if (!src.thumbnailPng) return null;
return 'data:image/jpeg;base64,' + src.thumbnailPng;
}
+165
View File
@@ -0,0 +1,165 @@
// Toggle-style voice hotkeys (mute / deafen). Distinct from PTT (hold-style
// single-key). Keeps the Discord muscle-memory defaults — Ctrl+Shift+M for
// mute, Ctrl+Shift+D for deafen — but ships disabled so they never collide
// with something else on first run.
//
// Chord-capable: each binding stores a base key (KeyboardEvent.code) plus
// modifier flags. The keyLabel field is pre-rendered so UI and in-call hints
// don't have to derive it on every render.
import { keyCodeToLabel } from './pttSettings';
const STORAGE_KEY = 'chatapp.voiceHotkeys.v1';
export interface VoiceHotkeyBinding {
/** KeyboardEvent.code of the base key. */
key: string;
/** Pre-rendered label. Includes modifier prefixes, e.g. "Ctrl+Shift+M". */
keyLabel: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
enabled: boolean;
}
export interface VoiceHotkeys {
mute: VoiceHotkeyBinding;
deafen: VoiceHotkeyBinding;
}
export type VoiceHotkeyKind = keyof VoiceHotkeys;
const DEFAULTS: VoiceHotkeys = {
mute: {
key: 'KeyM',
keyLabel: 'Ctrl+Shift+M',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
deafen: {
key: 'KeyD',
keyLabel: 'Ctrl+Shift+D',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
};
type Listener = (s: VoiceHotkeys) => void;
const listeners = new Set<Listener>();
let cached: VoiceHotkeys | null = null;
function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotkeyBinding {
if (!raw || typeof raw !== 'object') return fallback;
const b = raw as Partial<VoiceHotkeyBinding>;
return {
key: typeof b.key === 'string' && b.key ? b.key : fallback.key,
keyLabel: typeof b.keyLabel === 'string' && b.keyLabel ? b.keyLabel : fallback.keyLabel,
ctrl: typeof b.ctrl === 'boolean' ? b.ctrl : fallback.ctrl,
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
};
}
function read(): VoiceHotkeys {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<VoiceHotkeys>;
cached = {
mute: validateBinding(parsed.mute, DEFAULTS.mute),
deafen: validateBinding(parsed.deafen, DEFAULTS.deafen),
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: VoiceHotkeys): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getVoiceHotkeys(): VoiceHotkeys {
return read();
}
export function updateVoiceHotkey(
kind: VoiceHotkeyKind,
patch: Partial<VoiceHotkeyBinding>,
): VoiceHotkeys {
const cur = read();
const next: VoiceHotkeys = {
...cur,
[kind]: {
...cur[kind],
...patch,
},
};
// Keep the label in sync with the key + modifier flags so callers don't
// have to remember to pass keyLabel too.
next[kind].keyLabel = renderBindingLabel(next[kind]);
write(next);
return next;
}
export function subscribeVoiceHotkeys(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Derives "Ctrl+Shift+M" from a binding. The base key is normalised via
// keyCodeToLabel so "KeyM" → "M" etc, matching the PTT label rendering.
export function renderBindingLabel(b: VoiceHotkeyBinding): string {
const parts: string[] = [];
if (b.ctrl) parts.push('Ctrl');
if (b.shift) parts.push('Shift');
if (b.alt) parts.push('Alt');
parts.push(keyCodeToLabel(b.key));
return parts.join('+');
}
// Tauri global-shortcut accelerator string format. `CommandOrControl` maps
// to Cmd on macOS and Ctrl on Windows/Linux so the same binding works on
// every platform without platform-specific storage.
export function bindingToTauriShortcut(b: VoiceHotkeyBinding): string {
const base = b.key.startsWith('Key')
? b.key.slice(3)
: b.key.startsWith('Digit')
? b.key.slice(5)
: b.key;
const parts: string[] = [];
if (b.ctrl) parts.push('CommandOrControl');
if (b.shift) parts.push('Shift');
if (b.alt) parts.push('Alt');
parts.push(base);
return parts.join('+');
}
// Check if a browser KeyboardEvent matches a binding exactly (including
// modifier state). Used by the window-level fallback listener when the
// Tauri global-shortcut registration isn't available.
export function eventMatchesBinding(b: VoiceHotkeyBinding, e: KeyboardEvent): boolean {
if (!b.enabled) return false;
if (e.code !== b.key) return false;
if (e.ctrlKey !== b.ctrl && e.metaKey !== b.ctrl) return false;
if (e.shiftKey !== b.shift) return false;
if (e.altKey !== b.alt) return false;
return true;
}
+5 -5
View File
@@ -29,15 +29,15 @@ export function AuthCallbackPage() {
if (state.kind === 'done') return <Navigate to="/chats" replace />; if (state.kind === 'done') return <Navigate to="/chats" replace />;
return ( return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6"> <main className="flex min-h-screen items-center justify-center bg-surface p-6 text-fg">
{state.kind === 'pending' ? ( {state.kind === 'pending' ? (
<div className="flex items-center gap-3 text-neutral-400"> <div className="flex items-center gap-3 text-fg-muted">
<SpinnerIcon className="h-5 w-5 text-brand-400" /> <SpinnerIcon className="h-5 w-5 text-accent" />
<span className="text-sm font-medium">{t('common:finalising_session')}</span> <span className="text-sm font-medium">{t('common:finalising_session')}</span>
</div> </div>
) : ( ) : (
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-4 text-sm text-rose-100"> <div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-4 text-sm text-rose-800 dark:text-rose-100">
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{state.message}</p> <p className="min-w-0 flex-1 break-words">{state.message}</p>
</div> </div>
)} )}
+180 -131
View File
@@ -84,112 +84,109 @@ export function AuthPage() {
[mode, email, username, inviteCode, i18n, t], [mode, email, username, inviteCode, i18n, t],
); );
// Already signed in? Bounce to chats. Guards take it from here.
if (session) return <Navigate to="/chats" replace />; if (session) return <Navigate to="/chats" replace />;
return ( return (
<Shell> <main className="relative min-h-screen overflow-hidden bg-surface text-fg">
<BrandPanel /> <ShellBackground />
<FormCard <div className="relative z-10 grid min-h-screen grid-cols-1 lg:grid-cols-[1fr_minmax(440px,520px)]">
mode={mode} <BrandSection />
onModeChange={setMode} <FormSection
email={email} mode={mode}
onEmailChange={setEmail} onModeChange={setMode}
username={username} email={email}
onUsernameChange={setUsername} onEmailChange={setEmail}
usernameValid={usernameValid} username={username}
inviteCode={inviteCode} onUsernameChange={setUsername}
onInviteChange={setInviteCode} usernameValid={usernameValid}
ui={ui} inviteCode={inviteCode}
onSubmit={handleSubmit} onInviteChange={setInviteCode}
/> ui={ui}
</Shell> onSubmit={handleSubmit}
); />
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<BackgroundStage />
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
{children}
</div> </div>
</main> </main>
); );
} }
function BackgroundStage() { // ---------------------------------------------------------------------------
// Shell background — ambient blobs spread across the full viewport. Anchored
// to percentages so they stay in the same relative position regardless of
// monitor width (works as well on 1440 as on 3440 ultrawide).
// ---------------------------------------------------------------------------
function ShellBackground() {
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.28]" /> <div className="bg-grid absolute inset-0 opacity-[0.12]" />
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" /> <div className="absolute left-[15%] top-[18%] h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-accent/25 blur-3xl" />
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" /> <div className="absolute left-[35%] top-[70%] h-[480px] w-[480px] -translate-x-1/2 rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" /> <div className="absolute left-[60%] top-[30%] h-[440px] w-[440px] -translate-x-1/2 rounded-full bg-indigo-500/15 blur-3xl" />
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.45)_100%)]" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
</div> </div>
); );
} }
function BrandPanel() { // ---------------------------------------------------------------------------
// Brand section — edge-to-edge panel on the left. Inner content constrained
// so it stays readable on ultrawide screens; ambient background fills the rest.
// ---------------------------------------------------------------------------
function BrandSection() {
const { t } = useTranslation(['auth', 'common']); const { t } = useTranslation(['auth', 'common']);
return ( return (
<section className="relative hidden lg:block"> <section className="relative hidden lg:flex">
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16"> <DecorativeBubbles />
<header className="flex items-center justify-between gap-3"> <div className="relative mx-auto grid h-full w-full max-w-2xl grid-rows-[auto_1fr_auto] gap-8 px-10 py-10 xl:max-w-3xl xl:px-14 xl:py-14">
<div className="flex items-center gap-3"> <header className="flex items-center gap-3">
<LogoMark className="h-9 w-9" /> <LogoMark className="h-9 w-9" />
<span className="font-display text-lg font-semibold tracking-tight"> <span className="font-display text-lg font-semibold tracking-tight text-fg">
{t('common:app_name')} {t('common:app_name')}
</span> </span>
</div>
<LanguageSwitcher />
</header> </header>
<div className="flex items-center"> <div className="flex flex-col justify-center">
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl"> <span className="inline-flex w-fit items-center gap-2 rounded-full border border-line bg-surface-2/70 px-3 py-1 text-xs font-medium text-fg-muted backdrop-blur">
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur"> <ShieldIcon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400" />
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" /> {t('auth:brand.badge')}
{t('auth:brand.badge')} </span>
</p>
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
{t('auth:brand.title_line_1')}
<br />
{t('auth:brand.title_line_2')}
</h1>
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
{t('auth:brand.subtitle')}
</p>
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3"> <h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-fg xl:text-5xl 2xl:text-6xl">
<Feature {t('auth:brand.title_line_1')}
icon={<LockIcon className="h-5 w-5 text-brand-300" />} <br />
title={t('auth:brand.feature_zk_title')} <span className="text-accent">{t('auth:brand.title_line_2')}</span>
desc={t('auth:brand.feature_zk_desc')} </h1>
/>
<Feature <p className="mt-5 max-w-xl text-base leading-relaxed text-fg-muted xl:text-lg">
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />} {t('auth:brand.subtitle')}
title={t('auth:brand.feature_selfhost_title')} </p>
desc={t('auth:brand.feature_selfhost_desc')}
/> <ul className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-1 xl:mt-12 xl:gap-4">
<Feature <FeatureRow
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />} icon={<LockIcon className="h-4 w-4" />}
title={t('auth:brand.feature_invite_title')} title={t('auth:brand.feature_zk_title')}
desc={t('auth:brand.feature_invite_desc')} desc={t('auth:brand.feature_zk_desc')}
/> />
</dl> <FeatureRow
</div> icon={<SparklesIcon className="h-4 w-4" />}
title={t('auth:brand.feature_selfhost_title')}
desc={t('auth:brand.feature_selfhost_desc')}
/>
<FeatureRow
icon={<ShieldIcon className="h-4 w-4" />}
title={t('auth:brand.feature_invite_title')}
desc={t('auth:brand.feature_invite_desc')}
/>
</ul>
</div> </div>
<footer className="flex items-center justify-between text-xs text-neutral-500"> <footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
<span>v0.1.0 · {t('common:dev_build')}</span> <span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
<span className="inline-flex items-center gap-1.5 text-neutral-600"> <span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> <span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{t('common:local_stack_online')} {t('common:local_stack_online')}
</span> </span>
</footer> </footer>
@@ -198,19 +195,49 @@ function BrandPanel() {
); );
} }
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) { // Subtle chat-bubble silhouettes in the far background — gives the brand side
// product context without competing with the typographic content.
function DecorativeBubbles() {
return ( return (
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur"> <div
<div className="flex items-center gap-2"> aria-hidden="true"
{icon} className="pointer-events-none absolute inset-0 hidden overflow-hidden opacity-60 xl:block"
<dt className="text-sm font-semibold text-white">{title}</dt> >
</div> <div className="absolute right-[8%] top-[22%] h-16 w-44 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd> <div className="absolute right-[18%] top-[42%] h-12 w-32 rounded-2xl rounded-br-sm border border-accent/20 bg-accent/10 backdrop-blur-sm" />
<div className="absolute right-[6%] top-[58%] h-14 w-40 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
</div> </div>
); );
} }
interface FormCardProps { function FeatureRow({
icon,
title,
desc,
}: {
icon: React.ReactNode;
title: string;
desc: string;
}) {
return (
<li className="flex items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/20">
{icon}
</span>
<div className="min-w-0 flex-1 pt-1">
<p className="text-sm font-semibold text-fg xl:text-base">{title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted xl:text-sm">{desc}</p>
</div>
</li>
);
}
// ---------------------------------------------------------------------------
// Form section — fixed-width panel on the right. Full viewport height,
// bg-surface-2 for visual distinction from the brand side.
// ---------------------------------------------------------------------------
interface FormSectionProps {
mode: Mode; mode: Mode;
onModeChange: (m: Mode) => void; onModeChange: (m: Mode) => void;
email: string; email: string;
@@ -224,7 +251,7 @@ interface FormCardProps {
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
} }
function FormCard({ function FormSection({
mode, mode,
onModeChange, onModeChange,
email, email,
@@ -236,7 +263,7 @@ function FormCard({
onInviteChange, onInviteChange,
ui, ui,
onSubmit, onSubmit,
}: FormCardProps) { }: FormSectionProps) {
const { t } = useTranslation(['auth']); const { t } = useTranslation(['auth']);
const busy = ui.kind === 'sending'; const busy = ui.kind === 'sending';
const emailId = useId(); const emailId = useId();
@@ -249,22 +276,30 @@ function FormCard({
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending'; const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
return ( return (
<section className="relative flex items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16"> <section className="relative flex flex-col border-t border-line bg-surface-2/80 backdrop-blur-xl lg:border-l lg:border-t-0">
<div className="absolute left-6 right-6 top-6 flex items-center justify-between lg:hidden"> {/* Mobile-only header strip */}
<div className="flex items-center justify-between border-b border-line px-5 py-4 lg:hidden">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<LogoMark className="h-7 w-7" /> <LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight">ChatApp</span> <span className="font-display text-base font-semibold tracking-tight text-fg">
ChatApp
</span>
</div> </div>
<LanguageSwitcher compact /> <LanguageSwitcher compact />
</div> </div>
<div className="w-full max-w-md animate-slide-up"> {/* Desktop-only top strip — LanguageSwitcher in the corner */}
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8"> <div className="hidden items-center justify-end px-8 pt-8 lg:flex">
<LanguageSwitcher />
</div>
<div className="flex flex-1 items-center">
<div className="mx-auto w-full max-w-md px-6 py-8 sm:px-8 lg:px-10 lg:py-10">
<header className="mb-6"> <header className="mb-6">
<h2 className="font-display text-2xl font-semibold tracking-tight text-white"> <h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t(titleKey)} {t(titleKey)}
</h2> </h2>
<p className="mt-1.5 text-sm text-neutral-400">{t(subtitleKey)}</p> <p className="mt-1.5 text-sm text-fg-muted">{t(subtitleKey)}</p>
</header> </header>
<Segmented mode={mode} onChange={onModeChange} /> <Segmented mode={mode} onChange={onModeChange} />
@@ -285,7 +320,7 @@ function FormCard({
placeholder={t('auth:fields.email_placeholder')} placeholder={t('auth:fields.email_placeholder')}
value={email} value={email}
onChange={(e) => onEmailChange(e.target.value)} onChange={(e) => onEmailChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS}
/> />
} }
/> />
@@ -316,7 +351,7 @@ function FormCard({
value={username} value={username}
onChange={(e) => onUsernameChange(e.target.value)} onChange={(e) => onUsernameChange(e.target.value)}
pattern={USERNAME_PATTERN.source} pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS}
/> />
} }
/> />
@@ -336,7 +371,7 @@ function FormCard({
required required
value={inviteCode} value={inviteCode}
onChange={(e) => onInviteChange(e.target.value)} onChange={(e) => onInviteChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className={INPUT_CLASS + ' font-mono'}
/> />
} }
/> />
@@ -347,7 +382,7 @@ function FormCard({
type="submit" type="submit"
disabled={busy} disabled={busy}
aria-busy={busy} aria-busy={busy}
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60" className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy ? ( {busy ? (
<> <>
@@ -367,25 +402,30 @@ function FormCard({
</form> </form>
<Footer mode={mode} onModeChange={onModeChange} /> <Footer mode={mode} onModeChange={onModeChange} />
</div>
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p> <p className="mt-6 text-center text-xs text-fg-muted">
{t('auth:legal_note')}
</p>
</div>
</div> </div>
</section> </section>
); );
} }
const INPUT_CLASS =
'w-full rounded-lg border border-line bg-surface-3 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30';
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) { function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']); const { t } = useTranslation(['auth']);
return ( return (
<div <div
role="tablist" role="tablist"
aria-label="Authentication mode" aria-label="Authentication mode"
className="relative grid grid-cols-2 rounded-lg border border-white/10 bg-ink-800 p-1 text-sm" className="relative grid grid-cols-2 rounded-lg border border-line bg-surface-3 p-1 text-sm"
> >
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 transition-transform duration-200" className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-accent/15 ring-1 ring-accent/30 transition-transform duration-200"
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }} style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
/> />
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}> <SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
@@ -415,7 +455,7 @@ function SegmentButton({
onClick={onClick} onClick={onClick}
className={ className={
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' + 'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
(active ? 'text-white' : 'text-neutral-400 hover:text-neutral-200') (active ? 'text-fg' : 'text-fg-muted hover:text-fg')
} }
> >
{children} {children}
@@ -440,17 +480,27 @@ function Field({
}) { }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-medium uppercase tracking-wide text-neutral-400"> <label
htmlFor={id}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
>
{label} {label}
</label> </label>
<div className="relative"> <div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500"> <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-fg-muted">
{icon} {icon}
</span> </span>
{input} {input}
</div> </div>
{hint && ( {hint && (
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{hint}</p> <p
className={
'text-[11px] leading-snug ' +
(invalid ? 'text-rose-600 dark:text-rose-400' : 'text-fg-muted')
}
>
{hint}
</p>
)} )}
</div> </div>
); );
@@ -462,14 +512,14 @@ function StatusBanner({ ui }: { ui: UiState }) {
return ( return (
<div <div
role="status" role="status"
className="flex items-start gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100" className="flex items-start gap-3 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-3.5 text-sm text-emerald-800 dark:text-emerald-100"
> >
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" /> <CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-medium"> <p className="break-words font-medium">
{t('auth:sent_banner', { email: ui.email })} {t('auth:sent_banner', { email: ui.email })}
</p> </p>
<p className="break-words text-xs text-emerald-200/80"> <p className="break-words text-xs text-emerald-700/90 dark:text-emerald-200/80">
<InbucketHint /> <InbucketHint />
</p> </p>
</div> </div>
@@ -481,9 +531,9 @@ function StatusBanner({ ui }: { ui: UiState }) {
return ( return (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3.5 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-3.5 text-sm text-rose-800 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{ui.message}</p> <p className="min-w-0 flex-1 break-words">{ui.message}</p>
</div> </div>
); );
@@ -503,7 +553,7 @@ function InbucketHint() {
href="http://127.0.0.1:54324" href="http://127.0.0.1:54324"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="underline underline-offset-2 hover:text-white" className="underline underline-offset-2 hover:text-emerald-900 dark:hover:text-white"
> >
Inbucket Inbucket
</a> </a>
@@ -527,7 +577,6 @@ function OtpForm({ email }: { email: string }) {
setError(null); setError(null);
try { try {
await verifyMagicLinkOtp(supabase, email, token); await verifyMagicLinkOtp(supabase, email, token);
// Session updates via Supabase subscription; AuthPage Navigate redirects.
} catch (err: unknown) { } catch (err: unknown) {
const code = extractErrorCode(err); const code = extractErrorCode(err);
setError( setError(
@@ -543,10 +592,10 @@ function OtpForm({ email }: { email: string }) {
} }
return ( return (
<div className="mt-4 space-y-2 rounded-lg border border-white/10 bg-ink-800/50 p-4"> <div className="mt-4 space-y-2 rounded-lg border border-line bg-surface-3 p-4">
<label <label
htmlFor={inputId} htmlFor={inputId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400" className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
> >
{t('auth:otp_label')} {t('auth:otp_label')}
</label> </label>
@@ -567,14 +616,14 @@ function OtpForm({ email }: { email: string }) {
}} }}
placeholder={t('auth:otp_placeholder')} placeholder={t('auth:otp_placeholder')}
autoFocus autoFocus
className="w-full rounded-lg border border-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="w-full rounded-lg border border-line bg-surface-2 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-fg placeholder-fg-muted/50 transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p> <p className="text-[11px] text-fg-muted">{t('auth:otp_hint')}</p>
{error && ( {error && (
<p <p
role="alert" role="alert"
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200" className="break-words rounded-md border border-rose-500/25 bg-rose-500/10 px-3 py-2 text-xs text-rose-800 dark:text-rose-200"
> >
{error} {error}
</p> </p>
@@ -585,7 +634,7 @@ function OtpForm({ email }: { email: string }) {
disabled={busy || token.length !== 6} disabled={busy || token.length !== 6}
onClick={(e) => void handleVerify(e)} onClick={(e) => void handleVerify(e)}
aria-busy={busy} aria-busy={busy}
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60" className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy && <SpinnerIcon className="h-4 w-4" />} {busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span> <span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
@@ -601,13 +650,13 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup'; mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
return ( return (
<div className="mt-6 flex items-center justify-between border-t border-white/5 pt-5 text-xs text-neutral-500"> <div className="mt-6 flex items-center justify-between border-t border-line pt-5 text-xs text-fg-muted">
<span> <span>
{t(promptKey)}{' '} {t(promptKey)}{' '}
<button <button
type="button" type="button"
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')} onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900" className="cursor-pointer font-semibold text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2"
> >
{t(switchKey)} {t(switchKey)}
</button> </button>
@@ -616,7 +665,7 @@ function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
href="http://127.0.0.1:54323" href="http://127.0.0.1:54323"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="hover:text-neutral-300" className="hover:text-fg"
> >
{t('auth:footer_studio')} {t('auth:footer_studio')}
</a> </a>
+103
View File
@@ -25,6 +25,13 @@ import {
subscribePttSettings, subscribePttSettings,
updatePttSettings, updatePttSettings,
} from '../lib/pttSettings'; } from '../lib/pttSettings';
import {
getVoiceHotkeys,
subscribeVoiceHotkeys,
updateVoiceHotkey,
type VoiceHotkeyKind,
type VoiceHotkeys,
} from '../lib/voiceHotkeys';
import { import {
AUDIO_QUALITY_ORDER, AUDIO_QUALITY_ORDER,
type AudioQuality, type AudioQuality,
@@ -163,6 +170,12 @@ export function SettingsPage() {
<div className="mt-3 border-t border-line pt-3"> <div className="mt-3 border-t border-line pt-3">
<PttControls /> <PttControls />
</div> </div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="mute" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="deafen" />
</div>
<div className="mt-3 border-t border-line pt-3"> <div className="mt-3 border-t border-line pt-3">
<CallE2EEControls /> <CallE2EEControls />
</div> </div>
@@ -263,6 +276,96 @@ function PttControls() {
); );
} }
function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
const { t } = useTranslation(['app']);
const [hotkeys, setHotkeys] = useState<VoiceHotkeys>(() => getVoiceHotkeys());
const [capturing, setCapturing] = useState(false);
useEffect(() => subscribeVoiceHotkeys(setHotkeys), []);
useEffect(() => {
if (!capturing) return;
const onKey = (e: KeyboardEvent) => {
// Modifier-only presses shouldn't bind — wait for a real key to
// arrive. Escape aborts the capture.
if (e.code === 'Escape') {
e.preventDefault();
setCapturing(false);
return;
}
if (
e.code === 'ControlLeft' ||
e.code === 'ControlRight' ||
e.code === 'ShiftLeft' ||
e.code === 'ShiftRight' ||
e.code === 'AltLeft' ||
e.code === 'AltRight' ||
e.code === 'MetaLeft' ||
e.code === 'MetaRight'
) {
return;
}
e.preventDefault();
updateVoiceHotkey(kind, {
key: e.code,
ctrl: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
});
setCapturing(false);
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturing, kind]);
const binding = hotkeys[kind];
const toggleLabel =
kind === 'mute'
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
const toggleHint =
kind === 'mute'
? t('app:settings.hotkey_mute_hint', {
defaultValue:
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
})
: t('app:settings.hotkey_deafen_hint', {
defaultValue:
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
});
return (
<>
<Toggle
label={toggleLabel}
hint={toggleHint}
checked={binding.enabled}
onChange={(v) => updateVoiceHotkey(kind, { enabled: v })}
/>
<SettingRow
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
</SettingRow>
</>
);
}
function CallE2EEControls() { function CallE2EEControls() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings()); const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
+1
View File
@@ -26,6 +26,7 @@
"desktop": "pnpm --filter @chat-app/desktop", "desktop": "pnpm --filter @chat-app/desktop",
"desktop:dev": "pnpm --filter @chat-app/desktop dev", "desktop:dev": "pnpm --filter @chat-app/desktop dev",
"desktop:build": "pnpm --filter @chat-app/desktop build", "desktop:build": "pnpm --filter @chat-app/desktop build",
"release": "node scripts/release.mjs",
"db:types": "supabase gen types typescript --local > packages/db-types/src/index.ts", "db:types": "supabase gen types typescript --local > packages/db-types/src/index.ts",
"prod:migrate": "./scripts/prod/push-migrations.sh", "prod:migrate": "./scripts/prod/push-migrations.sh",
"prod:deploy-fn": "./scripts/prod/push-edge-function.sh", "prod:deploy-fn": "./scripts/prod/push-edge-function.sh",
+1 -1
View File
@@ -205,7 +205,7 @@
"ringtone_preview": "Vorhören", "ringtone_preview": "Vorhören",
"ringtone_stop": "Stop", "ringtone_stop": "Stop",
"ringtone_reset": "Zurücksetzen", "ringtone_reset": "Zurücksetzen",
"ringtone_hint": "MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.", "ringtone_hint": "MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.",
"ringtone_error_too_large": "Datei zu groß (max {{max}} MB).", "ringtone_error_too_large": "Datei zu groß (max {{max}} MB).",
"ringtone_error_not_audio": "Nur Audio-Dateien werden unterstützt.", "ringtone_error_not_audio": "Nur Audio-Dateien werden unterstützt.",
"ringtone_error_generic": "Ringtone konnte nicht gespeichert werden.", "ringtone_error_generic": "Ringtone konnte nicht gespeichert werden.",
+1 -1
View File
@@ -205,7 +205,7 @@
"ringtone_preview": "Preview", "ringtone_preview": "Preview",
"ringtone_stop": "Stop", "ringtone_stop": "Stop",
"ringtone_reset": "Reset", "ringtone_reset": "Reset",
"ringtone_hint": "MP3, WAV, OGG or M4A up to 2 MB. Incoming calls only — outgoing keeps the default.", "ringtone_hint": "MP3, WAV, OGG or M4A up to 8 MB. Incoming calls only — outgoing keeps the default.",
"ringtone_error_too_large": "File too large (max {{max}} MB).", "ringtone_error_too_large": "File too large (max {{max}} MB).",
"ringtone_error_not_audio": "Only audio files are supported.", "ringtone_error_not_audio": "Only audio files are supported.",
"ringtone_error_generic": "Could not save the ringtone.", "ringtone_error_generic": "Could not save the ringtone.",
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
// Release script — builds the Windows desktop installer, signs it with the
// Tauri updater key, and uploads the artifacts + latest.json to the update
// host over scp. Reads credentials from .env.release (not committed).
//
// Usage:
// pnpm release 0.10.2 "Ringtone cap auf 8 MB, bugfixes"
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const [, , versionArg, ...notesParts] = process.argv;
if (!versionArg || !/^\d+\.\d+\.\d+$/.test(versionArg)) {
console.error('Usage: pnpm release <x.y.z> "release notes"');
process.exit(1);
}
const notes = notesParts.join(' ').trim() || `Release ${versionArg}`;
const envPath = join(ROOT, '.env.release');
if (!existsSync(envPath)) {
console.error('.env.release missing. Copy .env.release.example and fill it in.');
process.exit(1);
}
const env = Object.fromEntries(
readFileSync(envPath, 'utf8')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
.map((l) => {
const i = l.indexOf('=');
return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
}),
);
const required = [
'TAURI_SIGNING_PRIVATE_KEY_PATH',
'UPDATE_HOST',
'UPDATE_SSH_USER',
'UPDATE_REMOTE_PATH',
];
for (const key of required) {
if (!env[key]) {
console.error(`Missing ${key} in .env.release`);
process.exit(1);
}
}
if (!existsSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH)) {
console.error(`Signing key not found at ${env.TAURI_SIGNING_PRIVATE_KEY_PATH}`);
process.exit(1);
}
const gitStatus = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf8' });
if (gitStatus.trim()) {
console.error('Working tree not clean. Commit or stash first.');
console.error(gitStatus);
process.exit(1);
}
const pkgJsonPath = join(ROOT, 'apps/desktop/package.json');
const tauriConfPath = join(ROOT, 'apps/desktop/src-tauri/tauri.conf.json');
const cargoTomlPath = join(ROOT, 'apps/desktop/src-tauri/Cargo.toml');
function bumpJson(path, version) {
const obj = JSON.parse(readFileSync(path, 'utf8'));
obj.version = version;
writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}
function bumpCargo(path, version) {
const text = readFileSync(path, 'utf8');
const next = text.replace(/^version = "[^"]+"$/m, `version = "${version}"`);
if (next === text) throw new Error(`No version line found in ${path}`);
writeFileSync(path, next, 'utf8');
}
bumpJson(pkgJsonPath, versionArg);
bumpJson(tauriConfPath, versionArg);
bumpCargo(cargoTomlPath, versionArg);
console.log(`Version -> ${versionArg}. Building NSIS bundle…`);
const signingKey = readFileSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, 'utf8').trim();
const buildEnv = {
...process.env,
TAURI_SIGNING_PRIVATE_KEY: signingKey,
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? '',
};
// Call the tauri binary directly (not via the desktop `build` script) so the
// `--` separator pnpm normally injects doesn't get forwarded to cargo. When
// run from cmd.exe (Node's default execSync shell on Windows) pnpm preserves
// the `--`, which cargo then rejects with "unexpected argument '--bundles'".
execSync('pnpm --filter @chat-app/desktop exec tauri build --bundles nsis', {
cwd: ROOT,
env: buildEnv,
stdio: 'inherit',
});
// Tauri v2 ships a single .exe + .exe.sig for NSIS updates — no .nsis.zip
// wrapper like v1. The updater downloads the .exe directly, verifies the
// minisign signature, then launches it in passive mode.
const bundleDir = join(ROOT, 'apps/desktop/src-tauri/target/release/bundle/nsis');
const exeName = `ChatApp_${versionArg}_x64-setup.exe`;
const sigName = `${exeName}.sig`;
const exePath = join(bundleDir, exeName);
const sigPath = join(bundleDir, sigName);
for (const p of [exePath, sigPath]) {
if (!existsSync(p)) {
console.error(`Missing build artifact: ${p}`);
process.exit(1);
}
}
const signature = readFileSync(sigPath, 'utf8').trim();
const latest = {
version: versionArg,
notes,
pub_date: new Date().toISOString(),
platforms: {
'windows-x86_64': {
signature,
url: `https://${env.UPDATE_HOST}/windows/${exeName}`,
},
},
};
const latestJsonPath = join(bundleDir, 'latest.json');
writeFileSync(latestJsonPath, JSON.stringify(latest, null, 2), 'utf8');
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
const keyFlag = env.UPDATE_SSH_KEY ? ` -i "${env.UPDATE_SSH_KEY}"` : '';
function scp(localPath) {
execSync(`scp${keyFlag} "${localPath}" ${sshTarget}:${env.UPDATE_REMOTE_PATH}/`, {
cwd: ROOT,
stdio: 'inherit',
});
}
console.log('Uploading artifacts (JSON last so clients never see a stale ref)…');
scp(exePath);
scp(sigPath);
scp(latestJsonPath);
execSync(
`git add apps/desktop/package.json apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/Cargo.toml`,
{ cwd: ROOT, stdio: 'inherit' },
);
execSync(`git commit -m "chore(desktop): release v${versionArg}"`, {
cwd: ROOT,
stdio: 'inherit',
});
execSync(`git tag v${versionArg}`, { cwd: ROOT, stdio: 'inherit' });
console.log(`\nReleased v${versionArg}`);
console.log(` Manifest: https://${env.UPDATE_HOST}/windows/latest.json`);
console.log(` Installer: https://${env.UPDATE_HOST}/windows/${exeName}`);
console.log(` Run 'git push && git push --tags' to sync to remote.`);