Compare commits
18
Commits
ff55d9d486
..
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce6bba0510 | ||
|
|
9ea399ded5 | ||
|
|
9f7723d104 | ||
|
|
5b84843217 | ||
|
|
bd8decb4b3 | ||
|
|
52f43d4d61 | ||
|
|
7eaba5e3f3 | ||
|
|
124db82a0c | ||
|
|
b4d001eb26 | ||
|
|
60c51a1635 | ||
|
|
d208b0c277 | ||
|
|
2f836cd366 | ||
|
|
f7f2cf75ab | ||
|
|
3deed92dd9 | ||
|
|
d525bbb8c5 | ||
|
|
310e9b86be | ||
|
|
e291b5e411 | ||
|
|
c899253e52 |
@@ -0,0 +1,97 @@
|
||||
# Caminho rápido: só Windows x64, disparado à mão.
|
||||
#
|
||||
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
|
||||
# publica release e alimenta o electron-updater). Este aqui existe para quando
|
||||
# você só quer um .exe para testar, sem marcar versão.
|
||||
#
|
||||
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
|
||||
# mexer nele criaria conflito a cada atualização.
|
||||
name: Build Windows (rápido)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: Arquitetura
|
||||
required: false
|
||||
default: x64
|
||||
type: choice
|
||||
options: [x64, arm64]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
|
||||
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
|
||||
# módulos nativos. Mesma razão documentada no release.yml.
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.34.3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
|
||||
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
|
||||
- name: Cache Electron binaries
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared package
|
||||
run: pnpm --filter @backspace/shared build
|
||||
|
||||
# O postinstall termina em `|| console.warn` para que quem não tem
|
||||
# ferramentas de build consiga instalar. No CI isso transforma falha em
|
||||
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
|
||||
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
|
||||
# resultado em vez de confiar no código de saída.
|
||||
- name: Verify native audio module compiled
|
||||
shell: bash
|
||||
run: |
|
||||
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
|
||||
if [ -z "$found" ]; then
|
||||
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
|
||||
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OK:"; echo "$found"
|
||||
|
||||
- name: Compile desktop TypeScript
|
||||
working-directory: packages/desktop
|
||||
run: pnpm exec tsc
|
||||
|
||||
# --publish never: sem release, sem precisar de tag nem bump de versão.
|
||||
# O instalador sai como artefato desta execução.
|
||||
- name: Build installer
|
||||
working-directory: packages/desktop
|
||||
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Upload installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backspace-windows-${{ inputs.arch || 'x64' }}
|
||||
path: packages/desktop/dist-electron/*.exe
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,183 @@
|
||||
# Compila no GitHub, publica no Gitea.
|
||||
#
|
||||
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
|
||||
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
|
||||
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
|
||||
# credencial embutida no app, o que aconteceria com um repositório privado no
|
||||
# GitHub.
|
||||
name: Publicar no Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Tag a publicar (ex. v1.1.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-2022
|
||||
args: --win --x64
|
||||
- os: ubuntu-latest
|
||||
args: --linux --x64
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev libxtst-dev libxt-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||
libxrandr-dev libxinerama-dev libx11-xcb-dev \
|
||||
libpipewire-0.3-dev libpulse-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.34.3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache Electron binaries
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared package
|
||||
run: pnpm --filter @backspace/shared build
|
||||
|
||||
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
|
||||
# de build. No CI isso esconde falha: o instalador sairia sem captura de
|
||||
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
|
||||
- name: Verify native audio module compiled
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
|
||||
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
|
||||
echo "$found"
|
||||
|
||||
- name: Compile desktop TypeScript
|
||||
working-directory: packages/desktop
|
||||
run: pnpm exec tsc
|
||||
|
||||
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
|
||||
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
|
||||
# passo seguinte faz o upload.
|
||||
- name: Build installers
|
||||
working-directory: packages/desktop
|
||||
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Upload to Gitea release
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
|
||||
TAG: latest
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::segredo GITEA_TOKEN não configurado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
|
||||
# engole a resposta de erro, então uma falha aqui só aparecia como um
|
||||
# JSONDecodeError sem dizer o motivo.
|
||||
api() {
|
||||
local method=$1 path=$2; shift 2
|
||||
curl -s -w '\n%{http_code}' -X "$method" \
|
||||
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
|
||||
}
|
||||
body() { sed '$d' <<<"$1"; }
|
||||
code() { tail -n1 <<<"$1"; }
|
||||
# jq em vez de python embutido: um heredoc multilinha dentro de um
|
||||
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
|
||||
json_id() { jq -r '.id // empty'; }
|
||||
|
||||
find_release() {
|
||||
local r; r=$(api GET "/releases/tags/$TAG")
|
||||
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
|
||||
}
|
||||
|
||||
# O corpo do POST fica em ASCII de proposito: o shell do runner
|
||||
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
|
||||
# "invalid UTF-8 within /name" (HTTP 422).
|
||||
ID=$(find_release)
|
||||
if [ -z "$ID" ]; then
|
||||
R=$(api POST "/releases" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
|
||||
if [ "$(code "$R")" = "201" ]; then
|
||||
ID=$(body "$R" | json_id)
|
||||
echo "release criada: $ID"
|
||||
else
|
||||
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
|
||||
# O outro job da matriz pode tê-la criado no mesmo instante.
|
||||
ID=$(find_release)
|
||||
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
|
||||
echo "release encontrada após corrida: $ID"
|
||||
fi
|
||||
else
|
||||
echo "release existente: $ID"
|
||||
fi
|
||||
|
||||
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||
# não apagarem o trabalho um do outro.
|
||||
R=$(api GET "/releases/$ID/assets")
|
||||
if [ "$(code "$R")" = "200" ]; then
|
||||
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
|
||||
case "$aname" in
|
||||
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
|
||||
echo "removendo anexo antigo: $aname"
|
||||
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
sent=0
|
||||
for f in packages/desktop/dist-electron/*.exe \
|
||||
packages/desktop/dist-electron/*.AppImage \
|
||||
packages/desktop/dist-electron/*.deb \
|
||||
packages/desktop/dist-electron/latest*.yml; do
|
||||
name=$(basename "$f")
|
||||
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
|
||||
if [ "$(code "$R")" != "201" ]; then
|
||||
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
|
||||
exit 1
|
||||
fi
|
||||
echo "enviado: $name"
|
||||
sent=$((sent+1))
|
||||
done
|
||||
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||
echo "$sent arquivo(s) publicados"
|
||||
@@ -14,22 +14,26 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# macOS — universal build (arm64 + x64) on Apple Silicon runner
|
||||
- os: macos-latest
|
||||
args: --mac --arm64 --x64
|
||||
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
|
||||
# dobrava o job mais lento (duas distribuições do Electron, módulos
|
||||
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
|
||||
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
|
||||
# instalava o fpm nativo perdeu a razão de existir: ele só era
|
||||
# necessário porque o fpm embutido no electron-builder é x86_64 e
|
||||
# falhava no runner arm64.
|
||||
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
|
||||
# é cobrado a 10x num repositório privado — era a plataforma mais cara
|
||||
# da matriz, compilada em toda tag para zero usuários.
|
||||
# Windows — x64 + arm64 on x64 runner.
|
||||
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
|
||||
# ships VS "18", which the node-gyp bundled with electron-rebuild
|
||||
# cannot detect ("unknown version undefined") when compiling
|
||||
# uiohook-napi in postinstall.
|
||||
- os: windows-2022
|
||||
args: --win --x64 --arm64
|
||||
args: --win --x64
|
||||
# Linux — x64 on x64 runner
|
||||
- os: ubuntu-latest
|
||||
args: --linux --x64
|
||||
# Linux — arm64 on arm64 runner
|
||||
- os: ubuntu-24.04-arm
|
||||
args: --linux --arm64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -59,17 +63,6 @@ jobs:
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||
libxrandr-dev libxinerama-dev libx11-xcb-dev
|
||||
|
||||
- name: Install fpm for .deb packaging
|
||||
if: runner.os == 'Linux'
|
||||
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
|
||||
# aborts the .deb target with "cannot execute binary file: Exec format
|
||||
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
|
||||
# arches package their .deb with a host-native fpm. ruby-dev + make are
|
||||
# needed for fpm's native gem dependencies.
|
||||
run: |
|
||||
sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
@@ -97,7 +90,3 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# Use the host-native fpm installed above instead of electron-builder's
|
||||
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
|
||||
# macOS/Windows, which don't build .deb.
|
||||
USE_SYSTEM_FPM: "true"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Gerar o instalador do app desktop
|
||||
|
||||
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
|
||||
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
|
||||
ao empurrar uma tag `v*`.
|
||||
|
||||
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
|
||||
|
||||
## Por que GitHub e não Gitea
|
||||
|
||||
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
|
||||
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
|
||||
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
|
||||
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
|
||||
WASAPI e só compila no Windows, com o compilador da Microsoft.
|
||||
|
||||
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
|
||||
espelho para compilar.
|
||||
|
||||
## Passos (uma vez)
|
||||
|
||||
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
|
||||
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
|
||||
2. Adicionar o espelho e empurrar:
|
||||
```
|
||||
cd /opt/backspace
|
||||
git remote add github git@github.com:syncwrld/resenhacord.git
|
||||
git push github main
|
||||
```
|
||||
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
|
||||
```
|
||||
git tag v1.0.1 && git push github v1.0.1
|
||||
```
|
||||
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
|
||||
|
||||
## O que isso resolve de quebra
|
||||
|
||||
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
|
||||
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
|
||||
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
|
||||
precisa reinstalar na mão de novo.
|
||||
|
||||
## Adaptações feitas para o fork
|
||||
|
||||
| O quê | Por quê |
|
||||
|---|---|
|
||||
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
|
||||
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
|
||||
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
|
||||
|
||||
## Detalhe herdado que vale preservar
|
||||
|
||||
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
|
||||
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
|
||||
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
|
||||
undefined"). Não mude isso sem testar.
|
||||
+13
-10
@@ -51,8 +51,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
|
||||
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
|
||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||
|
||||
@@ -86,21 +84,26 @@ modais de convite · configurações restantes · telas de erro
|
||||
| Problema | Causa provável | Correção |
|
||||
|---|---|---|
|
||||
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
|
||||
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
|
||||
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
|
||||
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
|
||||
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
|
||||
|
||||
|
||||
## Aprovadas para o app desktop (2026-08-31)
|
||||
## App desktop — já existia (verificado 2026-08-31)
|
||||
|
||||
Só entram aqui coisas que o navegador **não consegue** fazer — o resto seria
|
||||
trabalho dobrado sem ganho. **Nenhuma iniciada.**
|
||||
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
|
||||
construir de novo.**
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| **Áudio do sistema no compartilhamento de tela** | Média | Hoje o som do jogo/vídeo não vai junto com a tela. O Electron captura áudio do sistema; o navegador não. **É pré-requisito da watch party** — sem isso, assistir junto é assistir mudo. No Linux depende do servidor de áudio (PipeWire/PulseAudio), então vale confirmar o alvo antes |
|
||||
| **Bandeja do sistema** | Pequena | Fechar minimiza em vez de sair; ícone com menu de mudo/silenciar e sair de verdade. Cuidado clássico: sem um "sair" explícito no menu, a pessoa não consegue fechar o app |
|
||||
| **Notificações nativas do sistema** | Pequena | Mais confiáveis que as do navegador e funcionam com a janela minimizada. Já existe um `NotificationController` no web; a parte desktop é rotear pelo processo principal |
|
||||
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
|
||||
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
|
||||
- **Notificações nativas** — `showNotification()` no processo principal, canal
|
||||
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
|
||||
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
|
||||
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
|
||||
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
|
||||
|
||||
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
|
||||
|
||||
|
||||
## Aprovadas, a fazer depois (2026-08-31)
|
||||
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "backspace",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
|
||||
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": "Jannis Braun",
|
||||
"homepage": "https://github.com/TheZwiss/backspace",
|
||||
@@ -32,8 +32,9 @@
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild",
|
||||
"electron",
|
||||
"electron-native-screenshare",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -14,14 +14,27 @@ files:
|
||||
- "!**/node_modules/uiohook-napi/build/**"
|
||||
- "!**/node_modules/uiohook-napi/build.bak/**"
|
||||
- "!**/node_modules/uiohook-napi/bin/**"
|
||||
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
|
||||
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
|
||||
# below takes it out of the archive so the loader can find it.
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
npmRebuild: false
|
||||
afterPack: ./scripts/afterPack.js
|
||||
publish:
|
||||
- provider: github
|
||||
owner: TheZwiss
|
||||
repo: backspace
|
||||
# Updates are served from this fork's own Gitea, not from GitHub.
|
||||
#
|
||||
# GitHub is only the build machine — it has the Windows runners the native
|
||||
# audio module needs. Its repository is private, and electron-updater against
|
||||
# a private GitHub repo would need a token shipped inside the app, which is a
|
||||
# leaked token. Gitea serves release assets to anyone, so no credential ends
|
||||
# up in the installer.
|
||||
#
|
||||
# The tag is fixed at `latest` on purpose: electron-updater fetches
|
||||
# latest.yml before it knows which version exists, so the URL cannot contain
|
||||
# a version. CI replaces that release's assets on every publish.
|
||||
- provider: generic
|
||||
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
||||
protocols:
|
||||
- name: Backspace
|
||||
schemes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backspace/desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Backspace",
|
||||
@@ -18,9 +18,10 @@
|
||||
"clean": "rm -rf dist dist-electron",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
||||
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-native-screenshare": "^1.2.0",
|
||||
"electron-updater": "^6.3.0",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
|
||||
@@ -265,6 +265,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Native system-audio capture ────────────────────────────────────────────
|
||||
//
|
||||
// Electron's own `audio: 'loopback'` captures the whole output mix, which
|
||||
// includes this app playing everyone else's voices — so those voices went back
|
||||
// out inside the screen share and every listener heard themselves. Not acoustic
|
||||
// echo: it is a digital copy of the output, so headphones never helped.
|
||||
//
|
||||
// This module captures with process-level isolation instead: only the shared
|
||||
// window (include mode), or everything except this app (exclude mode).
|
||||
interface NativeAudioMeta {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
bitsPerSample: number;
|
||||
isFloat: boolean;
|
||||
}
|
||||
|
||||
interface NativeScreenShareAudio {
|
||||
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
|
||||
stopCapture(): boolean;
|
||||
getPidFromWindowHandle(windowHandle: number): number;
|
||||
isAvailable(): boolean;
|
||||
getLoadError(): string | null;
|
||||
}
|
||||
|
||||
let nativeAudio: NativeScreenShareAudio | null = null;
|
||||
try {
|
||||
// Required lazily and defensively: a native module that fails to load must
|
||||
// degrade to sharing without audio, never stop the app from starting.
|
||||
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
|
||||
if (!nativeAudio.isAvailable()) {
|
||||
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
|
||||
nativeAudio = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Main:ScreenShare] Native audio module missing:', err);
|
||||
nativeAudio = null;
|
||||
}
|
||||
|
||||
let nativeAudioActive = false;
|
||||
|
||||
/**
|
||||
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
|
||||
* Recovering the handle lets us capture only that window's audio, which is
|
||||
* better than excluding ourselves: a game's sound goes out, the rest of the
|
||||
* desktop does not.
|
||||
*/
|
||||
function windowHandleFromSourceId(sourceId: string): number | null {
|
||||
const match = /^window:(\d+)/.exec(sourceId);
|
||||
if (!match) return null;
|
||||
const handle = Number(match[1]);
|
||||
return Number.isFinite(handle) && handle > 0 ? handle : null;
|
||||
}
|
||||
|
||||
function startNativeAudioCapture(sourceId: string): boolean {
|
||||
if (!nativeAudio || nativeAudioActive) return false;
|
||||
|
||||
let targetPid = process.pid;
|
||||
let includeMode = false;
|
||||
|
||||
const handle = windowHandleFromSourceId(sourceId);
|
||||
if (handle !== null) {
|
||||
const pid = nativeAudio.getPidFromWindowHandle(handle);
|
||||
// pid 0 means the handle did not resolve; fall back to excluding ourselves
|
||||
// rather than capturing nothing.
|
||||
if (pid > 0) {
|
||||
targetPid = pid;
|
||||
includeMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('native-audio-data', data, meta);
|
||||
});
|
||||
nativeAudioActive = started;
|
||||
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
|
||||
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
|
||||
return started;
|
||||
} catch (err) {
|
||||
console.error('[Main:ScreenShare] Native audio start failed:', err);
|
||||
nativeAudioActive = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopNativeAudioCapture(): void {
|
||||
if (!nativeAudio || !nativeAudioActive) return;
|
||||
try {
|
||||
nativeAudio.stopCapture();
|
||||
} catch (err) {
|
||||
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
|
||||
}
|
||||
nativeAudioActive = false;
|
||||
}
|
||||
|
||||
// ─── Tray Icon ──────────────────────────────────────────────────────────────
|
||||
|
||||
function generateFallbackTrayIcon(): Electron.NativeImage {
|
||||
@@ -594,6 +690,10 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||
|
||||
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
||||
ipcMain.on('native-audio-stop', () => {
|
||||
stopNativeAudioCapture();
|
||||
});
|
||||
|
||||
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
|
||||
// Handled via ipcMain.once in the display media handler — this is just
|
||||
// a safety net to prevent unhandled-message warnings
|
||||
@@ -953,7 +1053,19 @@ if (!gotTheLock) {
|
||||
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
|
||||
// Fails on PipeWire-only systems without pulse compat — the
|
||||
// renderer catches that and toasts the user.
|
||||
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
|
||||
// Audio no longer rides on the Electron stream: `loopback` would put
|
||||
// this app's own output (everyone else's voices) back into the share.
|
||||
// The native module captures it separately, isolated by process, and
|
||||
// the renderer turns it into the track LiveKit publishes.
|
||||
if (shareAudio) {
|
||||
const started = startNativeAudioCapture(sourceId);
|
||||
if (!started) {
|
||||
// Tell the renderer so it can say the share is going out silently,
|
||||
// instead of the user assuming sound is included.
|
||||
mainWindow?.webContents.send('native-audio-unavailable');
|
||||
}
|
||||
}
|
||||
callback({ video: selected });
|
||||
} catch (err) {
|
||||
console.error('[Main:ScreenShare] Handler error:', err);
|
||||
// @ts-ignore — deny the request without crashing
|
||||
|
||||
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
||||
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
||||
},
|
||||
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
|
||||
// into a MediaStreamTrack for LiveKit.
|
||||
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
|
||||
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
|
||||
// Copied out of the transferred buffer: reusing it across IPC messages
|
||||
// would let a later chunk overwrite one still being read.
|
||||
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
|
||||
};
|
||||
ipcRenderer.on('native-audio-data', listener);
|
||||
return () => ipcRenderer.off('native-audio-data', listener);
|
||||
},
|
||||
onNativeAudioUnavailable: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on('native-audio-unavailable', listener);
|
||||
return () => ipcRenderer.off('native-audio-unavailable', listener);
|
||||
},
|
||||
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
|
||||
|
||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
||||
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
||||
},
|
||||
|
||||
@@ -110,14 +110,21 @@ interface SpotifyTrack {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Maps Spotify's payload onto the Activity shape the profile card renders. */
|
||||
/**
|
||||
* Maps Spotify's payload onto the Activity shape the profile card renders.
|
||||
*
|
||||
* A paused track is still reported, marked `paused`. Returning null for it made
|
||||
* the block disappear on every pause — and, together with the silent gap
|
||||
* between two songs, produced the flicker of it vanishing and coming back.
|
||||
*/
|
||||
function toActivity(track: SpotifyTrack): Activity | null {
|
||||
if (!track.is_playing || !track.item) return null;
|
||||
if (!track.item) return null;
|
||||
const now = Date.now();
|
||||
const progress = track.progress_ms ?? 0;
|
||||
return {
|
||||
type: 'listening',
|
||||
name: 'Spotify',
|
||||
paused: !track.is_playing,
|
||||
details: track.item.name,
|
||||
state: track.item.artists.map((a) => a.name).join(', '),
|
||||
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
|
||||
@@ -192,16 +199,16 @@ export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
|
||||
const token = await getAccessToken(request.userId);
|
||||
if (!token) return reply.code(200).send({ activity: null, connected: false });
|
||||
if (!token) return reply.code(200).send({ activity: null, connected: false, serverTime: Date.now() });
|
||||
|
||||
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
|
||||
// 204 means "nothing playing"; anything else non-OK is a transient problem
|
||||
// and must not be reported as a lost connection.
|
||||
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true });
|
||||
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401 });
|
||||
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true, serverTime: Date.now() });
|
||||
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
|
||||
|
||||
const track = await res.json() as SpotifyTrack;
|
||||
return reply.code(200).send({ activity: toActivity(track), connected: true });
|
||||
return reply.code(200).send({ activity: toActivity(track), connected: true, serverTime: Date.now() });
|
||||
});
|
||||
|
||||
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
|
||||
|
||||
@@ -474,6 +474,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
||||
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
||||
}
|
||||
|
||||
// Preserved through validation: without it the paused flag is stripped on
|
||||
// its way to everyone else, and the block resumes ticking on their screens.
|
||||
if (obj.paused === true) activity.paused = true;
|
||||
|
||||
if (obj.assets && typeof obj.assets === 'object') {
|
||||
const aObj = obj.assets as Record<string, unknown>;
|
||||
const assets: ActivityAssets = {};
|
||||
|
||||
@@ -1118,7 +1118,7 @@ class ConnectionManager {
|
||||
if (connections.size === 0) return;
|
||||
|
||||
const readyData = buildReadyPayload(userId);
|
||||
const message = JSON.stringify({ type: 'ready', ...readyData });
|
||||
const message = JSON.stringify({ type: 'ready', serverTime: Date.now(), ...readyData });
|
||||
for (const ws of connections) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(message);
|
||||
@@ -1792,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
const readyData = buildReadyPayload(userId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ready',
|
||||
// Lets each client measure its own offset from this server, so activity
|
||||
// timestamps computed here render correctly on a machine whose clock drifts.
|
||||
serverTime: Date.now(),
|
||||
...readyData,
|
||||
}));
|
||||
|
||||
|
||||
@@ -369,6 +369,12 @@ export interface ActivityAssets {
|
||||
export interface Activity {
|
||||
type: ActivityType;
|
||||
name: string;
|
||||
/**
|
||||
* Playback is paused. Kept as a state rather than dropping the activity:
|
||||
* pausing a track used to remove the block entirely, so it vanished and
|
||||
* reappeared on every pause and every gap between songs.
|
||||
*/
|
||||
paused?: boolean;
|
||||
details?: string;
|
||||
state?: string;
|
||||
timestamps?: ActivityTimestamps;
|
||||
@@ -427,7 +433,7 @@ export type ClientEvent =
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'ready'; serverTime?: number; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -328,7 +328,7 @@ export class BackspaceApiClient {
|
||||
readonly spotify: {
|
||||
status: () => Promise<{ configured: boolean; connected: boolean }>;
|
||||
authorizeUrl: () => Promise<{ url: string }>;
|
||||
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean }>;
|
||||
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean; serverTime?: number }>;
|
||||
disconnect: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -764,7 +764,7 @@ export class BackspaceApiClient {
|
||||
this.spotify = {
|
||||
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
||||
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
|
||||
nowPlaying: () => request<{ activity: Activity | null; connected: boolean }>('GET', '/connections/spotify/now-playing'),
|
||||
nowPlaying: () => request<{ activity: Activity | null; connected: boolean; serverTime?: number }>('GET', '/connections/spotify/now-playing'),
|
||||
disconnect: () => request<void>('DELETE', '/connections/spotify'),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
import { serverNow } from '../../utils/serverTime';
|
||||
|
||||
interface ProfileActivityProps {
|
||||
activities: Activity[];
|
||||
@@ -39,13 +40,21 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||
const start = primary?.timestamps?.start;
|
||||
const end = primary?.timestamps?.end;
|
||||
|
||||
// Re-render once a second only while there is a clock to advance.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const paused = primary?.paused === true;
|
||||
|
||||
// Ticks only while something is actually advancing: a paused track kept
|
||||
// counting until the next poll, so the bar walked past where the listener
|
||||
// had stopped.
|
||||
const [now, setNow] = useState(() => serverNow());
|
||||
useEffect(() => {
|
||||
if (!start) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
if (!start || paused) return;
|
||||
const id = setInterval(() => setNow(serverNow()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [start]);
|
||||
}, [start, paused]);
|
||||
|
||||
// Recompute once when playback resumes or the track changes, so the frozen
|
||||
// value is not what gets drawn.
|
||||
useEffect(() => { setNow(serverNow()); }, [start, paused]);
|
||||
|
||||
if (!primary || primary.type === 'custom') return null;
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
||||
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// Two-step add: pick the file, then name it in a field right here. The first
|
||||
// version asked with window.prompt, which Electron does not implement — it
|
||||
// returned nothing and the flow aborted in silence, so adding a sound worked
|
||||
// in the browser and did nothing at all in the desktop app.
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [pendingName, setPendingName] = useState('');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -50,28 +56,41 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
||||
// including the server's refusal when the cooldown is still running.
|
||||
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
const pickFile = (file: File) => {
|
||||
setError('');
|
||||
if (file.size > MAX_SOUND_BYTES) {
|
||||
setError(t('soundboard.tooLarge'));
|
||||
return;
|
||||
}
|
||||
const name = window.prompt(t('soundboard.namePrompt'), file.name.replace(/\.[^.]+$/, ''));
|
||||
if (!name) return;
|
||||
setPendingFile(file);
|
||||
setPendingName(file.name.replace(/\.[^.]+$/, '').slice(0, 32));
|
||||
};
|
||||
|
||||
const cancelPending = () => {
|
||||
setPendingFile(null);
|
||||
setPendingName('');
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const confirmPending = async () => {
|
||||
const file = pendingFile;
|
||||
const name = pendingName.trim();
|
||||
if (!file || !name) return;
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||
const { filename } = await waitForTransferAttachment(tid);
|
||||
const created = await api.soundboard.add(spaceId, name, filename);
|
||||
setSounds((prev) => [...prev, created]);
|
||||
cancelPending();
|
||||
} catch {
|
||||
// Distinct from the size check above: reporting every failure as "too
|
||||
// large" sends people to shrink a file that was never the problem.
|
||||
setError(t('soundboard.uploadFailed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,7 +130,7 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
if (file) pickFile(file);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -120,6 +139,46 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
||||
|
||||
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
|
||||
|
||||
{pendingFile && (
|
||||
<div className="mb-2 p-2 rounded-lg bg-surface-elevated/60">
|
||||
<label className="block text-[11px] text-txt-tertiary mb-1">
|
||||
{t('soundboard.namePrompt')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pendingName}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
onChange={(e) => setPendingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Scoped here so Enter does not reach the composer behind the popover.
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && pendingName.trim()) void confirmPending();
|
||||
if (e.key === 'Escape') cancelPending();
|
||||
}}
|
||||
className="input-search w-full mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmPending()}
|
||||
disabled={uploading || !pendingName.trim()}
|
||||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||||
>
|
||||
{uploading ? t('soundboard.adding') : t('soundboard.confirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelPending}
|
||||
disabled={uploading}
|
||||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||||
>
|
||||
{t('soundboard.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
import { useActivityStore } from '../stores/activityStore';
|
||||
import { setServerTime } from '../utils/serverTime';
|
||||
|
||||
/** While a track is playing. Short enough that a track change shows up quickly. */
|
||||
/** Ceiling between checks while a track is playing. */
|
||||
const POLL_CONNECTED_MS = 20_000;
|
||||
/** While the account is not linked — cheap heartbeat that notices a new link. */
|
||||
const POLL_IDLE_MS = 60_000;
|
||||
/** Never hammer the API, however close the track end looks. */
|
||||
const MIN_POLL_MS = 4_000;
|
||||
/**
|
||||
* How long a silent answer is tolerated before the block is taken down.
|
||||
*
|
||||
* Spotify reports "nothing playing" in the gap between two songs, so clearing
|
||||
* on the first empty answer made the block vanish and reappear between every
|
||||
* track.
|
||||
*/
|
||||
const EMPTY_GRACE_MS = 25_000;
|
||||
|
||||
function trackKey(activity: Activity | null): string {
|
||||
return activity ? `${activity.details ?? ''}|${activity.state ?? ''}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes what the user is listening to on Spotify as an activity.
|
||||
@@ -16,6 +32,8 @@ const POLL_IDLE_MS = 60_000;
|
||||
*/
|
||||
export function useSpotifyActivity(): void {
|
||||
const showActivity = useActivityStore((s) => s.showActivity);
|
||||
const lastKeyRef = useRef('');
|
||||
const emptySinceRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const setSource = useActivityStore.getState().setSourceActivities;
|
||||
@@ -35,16 +53,46 @@ export function useSpotifyActivity(): void {
|
||||
// Polling a hidden tab burns Spotify's rate limit for a screen nobody
|
||||
// is looking at; the next visible tick catches up.
|
||||
if (typeof document === 'undefined' || !document.hidden) {
|
||||
const { activity, connected } = await api.spotify.nowPlaying();
|
||||
const { activity, connected, serverTime } = await api.spotify.nowPlaying();
|
||||
if (cancelled) return;
|
||||
setSource('spotify', activity ? [activity] : []);
|
||||
delay = connected ? POLL_CONNECTED_MS : POLL_IDLE_MS;
|
||||
if (serverTime) setServerTime(serverTime);
|
||||
|
||||
if (activity) {
|
||||
emptySinceRef.current = 0;
|
||||
const key = trackKey(activity);
|
||||
// A new track goes out at once; progress-only updates can wait for
|
||||
// the debounce, which is what it is there for.
|
||||
const immediate = key !== lastKeyRef.current;
|
||||
lastKeyRef.current = key;
|
||||
setSource('spotify', [activity], { immediate });
|
||||
|
||||
// Check back just after this track should end, rather than landing
|
||||
// mid-song and showing everyone the previous one for another
|
||||
// twenty seconds.
|
||||
const end = activity.timestamps?.end;
|
||||
const remaining = end ? end - Date.now() + 1_000 : POLL_CONNECTED_MS;
|
||||
delay = Math.max(MIN_POLL_MS, Math.min(POLL_CONNECTED_MS, remaining));
|
||||
} else if (connected) {
|
||||
const now = Date.now();
|
||||
if (!emptySinceRef.current) emptySinceRef.current = now;
|
||||
if (now - emptySinceRef.current >= EMPTY_GRACE_MS) {
|
||||
lastKeyRef.current = '';
|
||||
setSource('spotify', []);
|
||||
}
|
||||
delay = MIN_POLL_MS;
|
||||
} else {
|
||||
lastKeyRef.current = '';
|
||||
emptySinceRef.current = 0;
|
||||
setSource('spotify', []);
|
||||
delay = POLL_IDLE_MS;
|
||||
}
|
||||
} else {
|
||||
delay = POLL_CONNECTED_MS;
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup or a logged-out session: keep the last known state and
|
||||
// retry, rather than reporting "stopped listening" on a transient error.
|
||||
delay = POLL_CONNECTED_MS;
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(() => void tick(), delay);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@
|
||||
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
||||
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
||||
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
||||
import { setServerTime } from '../utils/serverTime';
|
||||
import { sortDmChannels } from '../utils/dmSorting';
|
||||
import { registerSelfId } from '../utils/identity';
|
||||
import { getActiveRoom } from './useLiveKit';
|
||||
@@ -281,6 +282,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.serverTime) setServerTime(event.serverTime);
|
||||
|
||||
// Clear voice state only for the reconnecting origin before repopulating
|
||||
clearVoiceUsersForOrigin(origin);
|
||||
if (event.voiceStates) {
|
||||
|
||||
@@ -40,6 +40,8 @@ export const en = {
|
||||
'soundboard.adding': 'Uploading...',
|
||||
'soundboard.remove': 'Remove',
|
||||
'soundboard.namePrompt': 'Name for this sound',
|
||||
'soundboard.confirm': 'Add',
|
||||
'soundboard.cancel': 'Cancel',
|
||||
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
|
||||
'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.',
|
||||
'soundboard.uploadFailed': 'Could not upload that file. Try a different one.',
|
||||
|
||||
@@ -39,6 +39,8 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'soundboard.adding': 'Enviando...',
|
||||
'soundboard.remove': 'Remover',
|
||||
'soundboard.namePrompt': 'Nome deste som',
|
||||
'soundboard.confirm': 'Adicionar',
|
||||
'soundboard.cancel': 'Cancelar',
|
||||
'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.',
|
||||
'soundboard.tooLarge': 'O som precisa ter menos de 2 MB e poucos segundos.',
|
||||
'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.',
|
||||
|
||||
+5
@@ -61,6 +61,11 @@ interface BackspaceElectronAPI {
|
||||
// Screen share picker coordination
|
||||
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
|
||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
|
||||
onNativeAudioData?: (
|
||||
callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void,
|
||||
) => () => void;
|
||||
onNativeAudioUnavailable?: (callback: () => void) => () => void;
|
||||
stopNativeAudio?: () => void;
|
||||
|
||||
// Instance URL management
|
||||
getInstanceUrl: () => Promise<string | null>;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ActivityState {
|
||||
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
||||
setShowActivity: (show: boolean) => void;
|
||||
pushActivities: (activities: Activity[]) => void;
|
||||
setSourceActivities: (source: string, activities: Activity[]) => void;
|
||||
setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -85,13 +85,25 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
setSourceActivities: (source, activities) => {
|
||||
setSourceActivities: (source, activities, opts) => {
|
||||
if (activities.length === 0) bySource.delete(source);
|
||||
else bySource.set(source, activities);
|
||||
const merged = Array.from(bySource.values())
|
||||
.flat()
|
||||
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
|
||||
|
||||
if (!opts?.immediate) {
|
||||
get().pushActivities(merged);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skips the 5s debounce. That delay exists to coalesce a chatty producer,
|
||||
// but a track change happens once every few minutes and stacking it on top
|
||||
// of the poll interval is what made everyone else see the previous song.
|
||||
if (!get().showActivity) return;
|
||||
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
||||
set({ myActivities: merged });
|
||||
wsSendAll({ type: 'activity_update', activities: merged });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Room, Track, LocalAudioTrack, AudioPresets } from 'livekit-client';
|
||||
import { isElectron } from '../platform/platform';
|
||||
|
||||
/**
|
||||
* Turns the desktop app's native system-audio capture into a track LiveKit can
|
||||
* publish.
|
||||
*
|
||||
* The native module hands the main process raw PCM, which arrives here in
|
||||
* chunks over IPC. Web Audio has no "push samples" input, so each chunk is
|
||||
* scheduled as a buffer source on a running cursor — the standard way to play a
|
||||
* live stream without gaps.
|
||||
*
|
||||
* Exists because Electron's `audio: 'loopback'` captures the whole output mix,
|
||||
* this app's own playback included, so every listener heard themselves echo.
|
||||
*/
|
||||
class NativeScreenAudio {
|
||||
private ctx: AudioContext | null = null;
|
||||
private destination: MediaStreamAudioDestinationNode | null = null;
|
||||
private gain: GainNode | null = null;
|
||||
private limiter: DynamicsCompressorNode | null = null;
|
||||
private track: LocalAudioTrack | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private nextStartTime = 0;
|
||||
|
||||
/**
|
||||
* Held ahead of the cursor so a late chunk does not land in the past and get
|
||||
* dropped. Small enough not to be noticeable against the video.
|
||||
*/
|
||||
private static readonly BUFFER_AHEAD_S = 0.08;
|
||||
/** Beyond this the stream has stalled; resync rather than drift forever. */
|
||||
private static readonly MAX_DRIFT_S = 0.5;
|
||||
|
||||
/**
|
||||
* WASAPI hands back the process's own mix, which sits far below the level a
|
||||
* microphone track arrives at — listeners had to run the participant at 200%
|
||||
* to hear anything. Boosted here rather than asking every listener to
|
||||
* compensate, with a limiter after it so loud passages do not clip.
|
||||
*/
|
||||
private static readonly BOOST = 3.0;
|
||||
|
||||
isActive(): boolean {
|
||||
return this.track !== null;
|
||||
}
|
||||
|
||||
async start(room: Room): Promise<boolean> {
|
||||
if (!isElectron() || !window.backspace?.onNativeAudioData) return false;
|
||||
if (this.track) return true;
|
||||
|
||||
this.ctx = new AudioContext();
|
||||
this.destination = this.ctx.createMediaStreamDestination();
|
||||
|
||||
// source → gain → limiter → destination. The limiter is a compressor with
|
||||
// a high ratio and fast attack: it only engages on peaks the boost would
|
||||
// otherwise clip, and is transparent below that.
|
||||
this.gain = this.ctx.createGain();
|
||||
this.gain.gain.value = NativeScreenAudio.BOOST;
|
||||
this.limiter = this.ctx.createDynamicsCompressor();
|
||||
this.limiter.threshold.value = -6;
|
||||
this.limiter.knee.value = 0;
|
||||
this.limiter.ratio.value = 20;
|
||||
this.limiter.attack.value = 0.003;
|
||||
this.limiter.release.value = 0.1;
|
||||
this.gain.connect(this.limiter);
|
||||
this.limiter.connect(this.destination);
|
||||
|
||||
this.nextStartTime = 0;
|
||||
|
||||
this.unsubscribe = window.backspace.onNativeAudioData((data, meta) => {
|
||||
this.enqueue(data, meta);
|
||||
});
|
||||
|
||||
const mediaTrack = this.destination.stream.getAudioTracks()[0];
|
||||
if (!mediaTrack) {
|
||||
await this.stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.track = new LocalAudioTrack(mediaTrack);
|
||||
try {
|
||||
await room.localParticipant.publishTrack(this.track, {
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
// Defaults are tuned for speech: mono, low bitrate, and DTX on, which
|
||||
// stops transmitting during silence. On game and music audio that
|
||||
// reads as clipped and thin.
|
||||
audioPreset: AudioPresets.musicHighQualityStereo,
|
||||
forceStereo: true,
|
||||
dtx: false,
|
||||
red: false,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[NativeScreenAudio] publish failed', err);
|
||||
await this.stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(data: ArrayBuffer, meta: { sampleRate: number; channels: number; isFloat: boolean; bitsPerSample: number }): void {
|
||||
const ctx = this.ctx;
|
||||
const destination = this.destination;
|
||||
if (!ctx || !destination) return;
|
||||
|
||||
// Only float32 is handled: it is what every supported platform reports.
|
||||
// Anything else is dropped rather than played as noise.
|
||||
if (!meta.isFloat || meta.bitsPerSample !== 32) return;
|
||||
|
||||
const samples = new Float32Array(data);
|
||||
const channels = Math.max(1, meta.channels);
|
||||
const frames = Math.floor(samples.length / channels);
|
||||
if (frames === 0) return;
|
||||
|
||||
const buffer = ctx.createBuffer(channels, frames, meta.sampleRate);
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
// Interleaved in, planar out. The bounds are computed from the buffer's
|
||||
// own length, but the index signature is still optional under
|
||||
// noUncheckedIndexedAccess — a truncated final chunk reads as silence
|
||||
// rather than NaN, which would click.
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
channelData[frame] = samples[frame * channels + channel] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.gain ?? destination);
|
||||
|
||||
const now = ctx.currentTime;
|
||||
if (this.nextStartTime < now || this.nextStartTime > now + NativeScreenAudio.MAX_DRIFT_S) {
|
||||
this.nextStartTime = now + NativeScreenAudio.BUFFER_AHEAD_S;
|
||||
}
|
||||
source.start(this.nextStartTime);
|
||||
this.nextStartTime += buffer.duration;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
window.backspace?.stopNativeAudio?.();
|
||||
|
||||
if (this.track) {
|
||||
try {
|
||||
this.track.stop();
|
||||
} catch { /* already gone */ }
|
||||
this.track = null;
|
||||
}
|
||||
this.gain = null;
|
||||
this.limiter = null;
|
||||
this.destination = null;
|
||||
if (this.ctx) {
|
||||
try {
|
||||
await this.ctx.close();
|
||||
} catch { /* already closed */ }
|
||||
this.ctx = null;
|
||||
}
|
||||
this.nextStartTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const nativeScreenAudio = new NativeScreenAudio();
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Room, Track, BackupCodecPolicy } from 'livekit-client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { nativeScreenAudio } from './nativeScreenAudio';
|
||||
import { isElectron } from '../platform/platform';
|
||||
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||
import { getStreamingLimits } from '../stores/settingsStore';
|
||||
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
||||
@@ -253,7 +255,10 @@ export async function startScreenShare(room: Room): Promise<boolean> {
|
||||
try {
|
||||
// For native mode: omit resolution constraint to capture at display's full native resolution
|
||||
const captureOptions: any = {
|
||||
audio: config.shareAudio ? {
|
||||
// In the desktop app audio never comes through this stream: Electron
|
||||
// ignores these constraints and its loopback would carry our own output
|
||||
// back into the share. The native capture below supplies it instead.
|
||||
audio: config.shareAudio && !isElectron() ? {
|
||||
// Chrome 141+: exclude this tab's own audio from system audio capture
|
||||
// @ts-ignore — restrictOwnAudio is not yet in all TS type definitions
|
||||
restrictOwnAudio: true,
|
||||
@@ -287,6 +292,14 @@ export async function startScreenShare(room: Room): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Desktop only, and only after the video track exists: publishing the audio
|
||||
// first would briefly show a share with sound and no picture. A failure
|
||||
// here leaves the share running silently rather than tearing it down.
|
||||
if (isElectron() && config.shareAudio) {
|
||||
const ok = await nativeScreenAudio.start(room);
|
||||
if (!ok) console.warn('[SS] native system audio unavailable — sharing without sound');
|
||||
}
|
||||
|
||||
// Set content hint from builder (motion for gaming, detail for text)
|
||||
const screenPub = room.localParticipant.getTrackPublications()
|
||||
.find(p => p.source === Track.Source.ScreenShare);
|
||||
@@ -416,6 +429,9 @@ function scheduleEncoderDetection(room: Room): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function stopScreenShare(room: Room): Promise<void> {
|
||||
// Stopped first: leaving the native capture running would keep reading system
|
||||
// audio after the share is gone.
|
||||
await nativeScreenAudio.stop();
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Offset between this machine's clock and the server's, in milliseconds.
|
||||
*
|
||||
* Activity timestamps (a Spotify track's start and end, for instance) are
|
||||
* computed on the server and rendered here. A machine whose clock is a minute
|
||||
* off would draw the progress bar a minute out of place — or past the end of
|
||||
* the track — with nothing obviously wrong on screen.
|
||||
*/
|
||||
let offsetMs = 0;
|
||||
|
||||
/** Called on every `ready`: the round trip is short enough to ignore. */
|
||||
export function setServerTime(serverTime: number): void {
|
||||
offsetMs = Date.now() - serverTime;
|
||||
}
|
||||
|
||||
/** `Date.now()` as the server would report it. */
|
||||
export function serverNow(): number {
|
||||
return Date.now() - offsetMs;
|
||||
}
|
||||
Generated
+21
-2
@@ -25,6 +25,9 @@ importers:
|
||||
|
||||
packages/desktop:
|
||||
dependencies:
|
||||
electron-native-screenshare:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
electron-updater:
|
||||
specifier: ^6.3.0
|
||||
version: 6.8.3
|
||||
@@ -876,11 +879,11 @@ packages:
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
|
||||
'@esbuild-kit/esm-loader@2.6.5':
|
||||
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
|
||||
'@esbuild/aix-ppc64@0.19.12':
|
||||
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
||||
@@ -2119,6 +2122,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
@@ -2158,6 +2162,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
@@ -2953,6 +2958,11 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
electron-native-screenshare@1.2.0:
|
||||
resolution: {integrity: sha512-O7m+/AeE0UWFq8Z18THGSrPHRIxYiYtjdMKtVhGcNRE/sUN6Fj9EgXDOmmJnORCh303NWaNgmAnNz+JbeLa48Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
os: [win32, linux, darwin]
|
||||
|
||||
electron-publish@25.1.7:
|
||||
resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==}
|
||||
|
||||
@@ -4184,6 +4194,9 @@ packages:
|
||||
node-addon-api@1.7.2:
|
||||
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
@@ -8257,6 +8270,10 @@ snapshots:
|
||||
- electron-builder-squirrel-windows
|
||||
- supports-color
|
||||
|
||||
electron-native-screenshare@1.2.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
electron-publish@25.1.7:
|
||||
dependencies:
|
||||
'@types/fs-extra': 9.0.13
|
||||
@@ -9929,6 +9946,8 @@ snapshots:
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Geradores dos efeitos sonoros
|
||||
|
||||
Os `.ogg` em `packages/web/public/sounds/` para call e transmissão são
|
||||
sintetizados por estes scripts — nenhum arquivo de terceiros, nenhuma questão
|
||||
de licença num repositório público.
|
||||
|
||||
Os parâmetros não foram escolhidos a gosto: saíram da **medição** de dois sons
|
||||
de referência fornecidos pelo dono da instância (análise de envelope, picos
|
||||
espectrais e tempo de decaimento). O timbre resultante é fundamental + oitava
|
||||
quase na mesma intensidade, com pouco acima disso:
|
||||
|
||||
| Harmônico | Proporção medida |
|
||||
|---|---|
|
||||
| 1x (fundamental) | 1.00 |
|
||||
| 2x (oitava) | 0.85 |
|
||||
| 3x | 0.10 |
|
||||
| 4x | 0.02 |
|
||||
|
||||
Decaimento a 1/e: ~0,19 s. Sem reverberação.
|
||||
|
||||
| Script | Gera | Notas |
|
||||
|---|---|---|
|
||||
| `build.py` | `user_join`, `user_leave` | C4→G4 subindo / D4→G3 descendo |
|
||||
| `stream.py` | `stream_user_joined`, `stream_user_left` | nota única (G4 / G3), ~55% do volume |
|
||||
| `started4.py` | `stream_started` | acorde de dó + golpe grave + sopro de ar |
|
||||
| `ended.py` | `stream_ended` | arpejo descendo, resolve na raiz grave |
|
||||
|
||||
Para regerar: `python3 <script>.py` produz `.wav`, depois converter com
|
||||
`ffmpeg -i X.wav -c:a libvorbis -q:a 5 -ar 48000 X.ogg`.
|
||||
|
||||
Ajustes comuns: `HARM` muda o timbre, `tau` a duração, `step` a velocidade do
|
||||
arpejo, e o alvo em `write()` o volume (pico RMS).
|
||||
@@ -0,0 +1,47 @@
|
||||
import math, wave, struct
|
||||
SR=48000
|
||||
# Proporcoes medidas nas referencias: fundamental, oitava, 3o, 4o
|
||||
HARM=(1.00, 0.85, 0.10, 0.02)
|
||||
TAU=0.19 # decaimento a 1/e medido
|
||||
ATTACK=0.004
|
||||
|
||||
def note(f, dur):
|
||||
n=int(SR*dur); o=[]
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
|
||||
o.append(s*min(1.0,t/ATTACK)*math.exp(-t/TAU))
|
||||
return o
|
||||
|
||||
def mix(layers,total):
|
||||
b=[0.0]*total
|
||||
for off,s in layers:
|
||||
for i,v in enumerate(s):
|
||||
if off+i<total: b[off+i]+=v
|
||||
return b
|
||||
|
||||
def peak_rms(x, win=int(SR*0.01)):
|
||||
best=0.0
|
||||
for i in range(0,len(x)-win,win):
|
||||
seg=x[i:i+win]
|
||||
best=max(best, math.sqrt(sum(v*v for v in seg)/len(seg)))
|
||||
return best
|
||||
|
||||
def write(name, buf, target_rms):
|
||||
cur=peak_rms(buf)
|
||||
g=target_rms/max(1e-9,cur)
|
||||
# nao deixa estourar mesmo casando o volume da referencia
|
||||
pk=max(abs(s*g) for s in buf)
|
||||
if pk>0.97: g*=0.97/pk
|
||||
with wave.open(name,'w') as w:
|
||||
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
|
||||
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
|
||||
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
|
||||
|
||||
C4,D4,G3,G4 = 261.63, 293.66, 196.00, 392.00
|
||||
GAP=int(SR*0.10) # 100ms entre as notas, medido
|
||||
|
||||
# join: quinta subindo — mesmas notas da referencia
|
||||
write('user_join.wav', mix([(0,note(C4,0.55)),(GAP,note(G4,0.55))], int(SR*0.62)), 0.365)
|
||||
# leave: quinta descendo
|
||||
write('user_leave.wav', mix([(0,note(D4,0.55)),(GAP,note(G3,0.55))], int(SR*0.62)), 0.440)
|
||||
@@ -0,0 +1,53 @@
|
||||
import math, wave, struct
|
||||
SR=48000
|
||||
HARM=(1.00, 0.85, 0.10, 0.02)
|
||||
|
||||
def note(f, dur, tau, amp=1.0):
|
||||
n=int(SR*dur); o=[]
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
|
||||
o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau))
|
||||
return o
|
||||
|
||||
def thump(f, dur, amp):
|
||||
n=int(SR*dur); o=[]; ph=0.0
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
ph += 2*math.pi*(f*(1+1.2*math.exp(-t/0.02)))/SR
|
||||
o.append(math.sin(ph)*amp*math.exp(-t/(dur*0.30)))
|
||||
return o
|
||||
|
||||
def mix(layers,total):
|
||||
b=[0.0]*total
|
||||
for off,s in layers:
|
||||
for i,v in enumerate(s):
|
||||
if off+i<total: b[off+i]+=v
|
||||
return b
|
||||
|
||||
def peak_rms(x, win=int(SR*0.01)):
|
||||
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win) for i in range(0,len(x)-win,win))
|
||||
|
||||
def write(name, buf, target):
|
||||
g=target/max(1e-9,peak_rms(buf))
|
||||
pk=max(abs(s*g) for s in buf)
|
||||
if pk>0.97: g*=0.97/pk
|
||||
with wave.open(name,'w') as w:
|
||||
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
|
||||
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
|
||||
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
|
||||
|
||||
C3,C4,E4,G4,C5 = 130.81,261.63,329.63,392.00,523.25
|
||||
step=int(SR*0.033)
|
||||
|
||||
# Espelho do inicio: o arpejo DESCE e resolve na raiz grave, que entra por
|
||||
# ultimo. Sem o sopro de ar — brilho sugere chegada, nao encerramento.
|
||||
# Mais curto e mais baixo: encerrar merece menos destaque que comecar.
|
||||
write('stream_ended.wav', mix([
|
||||
(0, note(C5, 0.42, 0.15, 0.70)),
|
||||
(step, note(G4, 0.44, 0.16)),
|
||||
(2*step, note(E4, 0.46, 0.17)),
|
||||
(3*step, note(C4, 0.48, 0.19)),
|
||||
(3*step, note(C3, 0.50, 0.21, 0.80)),
|
||||
(3*step, thump(85, 0.13, 0.40)),
|
||||
], int(SR*0.55)), 0.34)
|
||||
@@ -0,0 +1,65 @@
|
||||
import math, random, wave, struct
|
||||
SR=48000; random.seed(11)
|
||||
HARM=(1.00, 0.85, 0.10, 0.02)
|
||||
|
||||
def note(f, dur, tau, amp=1.0):
|
||||
n=int(SR*dur); o=[]
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
|
||||
o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau))
|
||||
return o
|
||||
|
||||
def thump(f, dur, amp):
|
||||
"""Instrumento 2: golpe grave com queda de tom. Da impacto no ataque e
|
||||
morre em 90ms — engorda sem alongar."""
|
||||
n=int(SR*dur); o=[]; ph=0.0
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
ph += 2*math.pi*(f*(1+1.2*math.exp(-t/0.02)))/SR
|
||||
o.append(math.sin(ph)*amp*math.exp(-t/(dur*0.30)))
|
||||
return o
|
||||
|
||||
def air(dur, amp, cutoff=5000):
|
||||
"""Instrumento 3: sopro curto de ruido filtrado. Brilho no inicio, sem cauda."""
|
||||
n=int(SR*dur)
|
||||
y=0.0; a=1-math.exp(-2*math.pi*cutoff/SR); o=[]
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
y += a*(random.uniform(-1,1)-y)
|
||||
o.append(y*amp*min(1.0,t/0.008)*math.exp(-t/(dur*0.22)))
|
||||
return o
|
||||
|
||||
def mix(layers,total):
|
||||
b=[0.0]*total
|
||||
for off,s in layers:
|
||||
for i,v in enumerate(s):
|
||||
if off+i<total: b[off+i]+=v
|
||||
return b
|
||||
|
||||
def peak_rms(x, win=int(SR*0.01)):
|
||||
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win) for i in range(0,len(x)-win,win))
|
||||
|
||||
def write(name, buf, target):
|
||||
g=target/max(1e-9,peak_rms(buf))
|
||||
pk=max(abs(s*g) for s in buf)
|
||||
if pk>0.97: g*=0.97/pk
|
||||
with wave.open(name,'w') as w:
|
||||
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
|
||||
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
|
||||
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
|
||||
|
||||
C3,G3,C4,E4,G4,C5 = 130.81,196.00,261.63,329.63,392.00,523.25
|
||||
step=int(SR*0.033) # arpejo bem rapido: acorde montado em 100ms
|
||||
|
||||
# Decaimentos ~metade dos anteriores (0.20 vs 0.38). Total 0.62s vs 1.08s.
|
||||
write('stream_started.wav', mix([
|
||||
(0, thump(85, 0.14, 0.55)), # impacto
|
||||
(0, air(0.13, 0.16)), # brilho
|
||||
(0, note(C3, 0.60, 0.22, 0.80)),
|
||||
(0, note(G3, 0.58, 0.20, 0.45)),
|
||||
(0, note(C4, 0.56, 0.20)),
|
||||
(step, note(E4, 0.54, 0.19)),
|
||||
(2*step, note(G4, 0.52, 0.18)),
|
||||
(3*step, note(C5, 0.50, 0.16, 0.70)),
|
||||
], int(SR*0.62)), 0.42)
|
||||
@@ -0,0 +1,31 @@
|
||||
import math, wave, struct
|
||||
SR=48000
|
||||
HARM=(1.00, 0.85, 0.10, 0.02) # mesmo timbre medido nas referencias
|
||||
ATTACK=0.004
|
||||
|
||||
def note(f, dur, tau):
|
||||
n=int(SR*dur); o=[]
|
||||
for i in range(n):
|
||||
t=i/SR
|
||||
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
|
||||
o.append(s*min(1.0,t/ATTACK)*math.exp(-t/tau))
|
||||
return o
|
||||
|
||||
def peak_rms(x, win=int(SR*0.01)):
|
||||
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win)
|
||||
for i in range(0,len(x)-win,win))
|
||||
|
||||
def write(name, buf, target):
|
||||
g=target/max(1e-9,peak_rms(buf))
|
||||
pk=max(abs(s*g) for s in buf)
|
||||
if pk>0.97: g*=0.97/pk
|
||||
with wave.open(name,'w') as w:
|
||||
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
|
||||
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
|
||||
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
|
||||
|
||||
G3, G4 = 196.00, 392.00
|
||||
# Decaimento mais curto (0.13 vs 0.19) e volume ~55% do som de call:
|
||||
# entrar/sair da tela dispara muitas vezes durante uma transmissao.
|
||||
write('stream_user_joined.wav', note(G4, 0.42, 0.13), 0.20)
|
||||
write('stream_user_left.wav', note(G3, 0.42, 0.13), 0.20)
|
||||
Reference in New Issue
Block a user