Compare commits
2
Commits
ff55d9d486
...
e291b5e411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e291b5e411 | ||
|
|
c899253e52 |
+12
-10
@@ -51,8 +51,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@@ -91,16 +89,20 @@ modais de convite · configurações restantes · telas de erro
|
|||||||
| **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 |
|
| **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
|
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
|
||||||
trabalho dobrado sem ganho. **Nenhuma iniciada.**
|
construir de novo.**
|
||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
|
||||||
|---|---|---|
|
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
|
||||||
| **Á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 |
|
- **Notificações nativas** — `showNotification()` no processo principal, canal
|
||||||
| **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 |
|
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
|
||||||
| **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 |
|
- **Á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)
|
## Aprovadas, a fazer depois (2026-08-31)
|
||||||
|
|||||||
@@ -110,14 +110,21 @@ interface SpotifyTrack {
|
|||||||
} | null;
|
} | 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 {
|
function toActivity(track: SpotifyTrack): Activity | null {
|
||||||
if (!track.is_playing || !track.item) return null;
|
if (!track.item) return null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const progress = track.progress_ms ?? 0;
|
const progress = track.progress_ms ?? 0;
|
||||||
return {
|
return {
|
||||||
type: 'listening',
|
type: 'listening',
|
||||||
name: 'Spotify',
|
name: 'Spotify',
|
||||||
|
paused: !track.is_playing,
|
||||||
details: track.item.name,
|
details: track.item.name,
|
||||||
state: track.item.artists.map((a) => a.name).join(', '),
|
state: track.item.artists.map((a) => a.name).join(', '),
|
||||||
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
|
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) => {
|
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const token = await getAccessToken(request.userId);
|
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}` } });
|
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
// 204 means "nothing playing"; anything else non-OK is a transient problem
|
// 204 means "nothing playing"; anything else non-OK is a transient problem
|
||||||
// and must not be reported as a lost connection.
|
// and must not be reported as a lost connection.
|
||||||
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true });
|
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 });
|
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
|
||||||
|
|
||||||
const track = await res.json() as SpotifyTrack;
|
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) => {
|
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;
|
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') {
|
if (obj.assets && typeof obj.assets === 'object') {
|
||||||
const aObj = obj.assets as Record<string, unknown>;
|
const aObj = obj.assets as Record<string, unknown>;
|
||||||
const assets: ActivityAssets = {};
|
const assets: ActivityAssets = {};
|
||||||
|
|||||||
@@ -1118,7 +1118,7 @@ class ConnectionManager {
|
|||||||
if (connections.size === 0) return;
|
if (connections.size === 0) return;
|
||||||
|
|
||||||
const readyData = buildReadyPayload(userId);
|
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) {
|
for (const ws of connections) {
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
@@ -1792,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
|||||||
const readyData = buildReadyPayload(userId);
|
const readyData = buildReadyPayload(userId);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'ready',
|
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,
|
...readyData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,12 @@ export interface ActivityAssets {
|
|||||||
export interface Activity {
|
export interface Activity {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
name: string;
|
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;
|
details?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
timestamps?: ActivityTimestamps;
|
timestamps?: ActivityTimestamps;
|
||||||
@@ -427,7 +433,7 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
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_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export class BackspaceApiClient {
|
|||||||
readonly spotify: {
|
readonly spotify: {
|
||||||
status: () => Promise<{ configured: boolean; connected: boolean }>;
|
status: () => Promise<{ configured: boolean; connected: boolean }>;
|
||||||
authorizeUrl: () => Promise<{ url: string }>;
|
authorizeUrl: () => Promise<{ url: string }>;
|
||||||
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean }>;
|
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean; serverTime?: number }>;
|
||||||
disconnect: () => Promise<void>;
|
disconnect: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -764,7 +764,7 @@ export class BackspaceApiClient {
|
|||||||
this.spotify = {
|
this.spotify = {
|
||||||
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
||||||
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
|
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'),
|
disconnect: () => request<void>('DELETE', '/connections/spotify'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import type { Activity } from '@backspace/shared';
|
import type { Activity } from '@backspace/shared';
|
||||||
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||||
import { useT, type TranslationKey } from '../../i18n';
|
import { useT, type TranslationKey } from '../../i18n';
|
||||||
|
import { serverNow } from '../../utils/serverTime';
|
||||||
|
|
||||||
interface ProfileActivityProps {
|
interface ProfileActivityProps {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
@@ -39,13 +40,21 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
|
|||||||
const start = primary?.timestamps?.start;
|
const start = primary?.timestamps?.start;
|
||||||
const end = primary?.timestamps?.end;
|
const end = primary?.timestamps?.end;
|
||||||
|
|
||||||
// Re-render once a second only while there is a clock to advance.
|
const paused = primary?.paused === true;
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!start) return;
|
if (!start || paused) return;
|
||||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
const id = setInterval(() => setNow(serverNow()), 1000);
|
||||||
return () => clearInterval(id);
|
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;
|
if (!primary || primary.type === 'custom') return null;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
|||||||
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
|
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 fileRef = useRef<HTMLInputElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(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.
|
// including the server's refusal when the cooldown is still running.
|
||||||
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
|
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const pickFile = (file: File) => {
|
||||||
setError('');
|
setError('');
|
||||||
if (file.size > MAX_SOUND_BYTES) {
|
if (file.size > MAX_SOUND_BYTES) {
|
||||||
setError(t('soundboard.tooLarge'));
|
setError(t('soundboard.tooLarge'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = window.prompt(t('soundboard.namePrompt'), file.name.replace(/\.[^.]+$/, ''));
|
setPendingFile(file);
|
||||||
if (!name) return;
|
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);
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
const { filename } = await waitForTransferAttachment(tid);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
const created = await api.soundboard.add(spaceId, name, filename);
|
const created = await api.soundboard.add(spaceId, name, filename);
|
||||||
setSounds((prev) => [...prev, created]);
|
setSounds((prev) => [...prev, created]);
|
||||||
|
cancelPending();
|
||||||
} catch {
|
} catch {
|
||||||
// Distinct from the size check above: reporting every failure as "too
|
// Distinct from the size check above: reporting every failure as "too
|
||||||
// large" sends people to shrink a file that was never the problem.
|
// large" sends people to shrink a file that was never the problem.
|
||||||
setError(t('soundboard.uploadFailed'));
|
setError(t('soundboard.uploadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileRef.current) fileRef.current.value = '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,7 +130,7 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
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>}
|
{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 ? (
|
{sounds.length === 0 ? (
|
||||||
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
|
<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 { api } from '../api/client';
|
||||||
import { useActivityStore } from '../stores/activityStore';
|
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;
|
const POLL_CONNECTED_MS = 20_000;
|
||||||
/** While the account is not linked — cheap heartbeat that notices a new link. */
|
/** While the account is not linked — cheap heartbeat that notices a new link. */
|
||||||
const POLL_IDLE_MS = 60_000;
|
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.
|
* 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 {
|
export function useSpotifyActivity(): void {
|
||||||
const showActivity = useActivityStore((s) => s.showActivity);
|
const showActivity = useActivityStore((s) => s.showActivity);
|
||||||
|
const lastKeyRef = useRef('');
|
||||||
|
const emptySinceRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setSource = useActivityStore.getState().setSourceActivities;
|
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
|
// Polling a hidden tab burns Spotify's rate limit for a screen nobody
|
||||||
// is looking at; the next visible tick catches up.
|
// is looking at; the next visible tick catches up.
|
||||||
if (typeof document === 'undefined' || !document.hidden) {
|
if (typeof document === 'undefined' || !document.hidden) {
|
||||||
const { activity, connected } = await api.spotify.nowPlaying();
|
const { activity, connected, serverTime } = await api.spotify.nowPlaying();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSource('spotify', activity ? [activity] : []);
|
if (serverTime) setServerTime(serverTime);
|
||||||
delay = connected ? POLL_CONNECTED_MS : POLL_IDLE_MS;
|
|
||||||
|
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 {
|
} else {
|
||||||
delay = POLL_CONNECTED_MS;
|
delay = POLL_CONNECTED_MS;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network hiccup or a logged-out session: keep the last known state and
|
// Network hiccup or a logged-out session: keep the last known state and
|
||||||
// retry, rather than reporting "stopped listening" on a transient error.
|
// retry, rather than reporting "stopped listening" on a transient error.
|
||||||
|
delay = POLL_CONNECTED_MS;
|
||||||
}
|
}
|
||||||
if (!cancelled) timer = setTimeout(() => void tick(), delay);
|
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 { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
||||||
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
||||||
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
||||||
|
import { setServerTime } from '../utils/serverTime';
|
||||||
import { sortDmChannels } from '../utils/dmSorting';
|
import { sortDmChannels } from '../utils/dmSorting';
|
||||||
import { registerSelfId } from '../utils/identity';
|
import { registerSelfId } from '../utils/identity';
|
||||||
import { getActiveRoom } from './useLiveKit';
|
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
|
// Clear voice state only for the reconnecting origin before repopulating
|
||||||
clearVoiceUsersForOrigin(origin);
|
clearVoiceUsersForOrigin(origin);
|
||||||
if (event.voiceStates) {
|
if (event.voiceStates) {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const en = {
|
|||||||
'soundboard.adding': 'Uploading...',
|
'soundboard.adding': 'Uploading...',
|
||||||
'soundboard.remove': 'Remove',
|
'soundboard.remove': 'Remove',
|
||||||
'soundboard.namePrompt': 'Name for this sound',
|
'soundboard.namePrompt': 'Name for this sound',
|
||||||
|
'soundboard.confirm': 'Add',
|
||||||
|
'soundboard.cancel': 'Cancel',
|
||||||
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
|
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
|
||||||
'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.',
|
'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.',
|
||||||
'soundboard.uploadFailed': 'Could not upload that file. Try a different one.',
|
'soundboard.uploadFailed': 'Could not upload that file. Try a different one.',
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'soundboard.adding': 'Enviando...',
|
'soundboard.adding': 'Enviando...',
|
||||||
'soundboard.remove': 'Remover',
|
'soundboard.remove': 'Remover',
|
||||||
'soundboard.namePrompt': 'Nome deste som',
|
'soundboard.namePrompt': 'Nome deste som',
|
||||||
|
'soundboard.confirm': 'Adicionar',
|
||||||
|
'soundboard.cancel': 'Cancelar',
|
||||||
'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.',
|
'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.tooLarge': 'O som precisa ter menos de 2 MB e poucos segundos.',
|
||||||
'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.',
|
'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ActivityState {
|
|||||||
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
||||||
setShowActivity: (show: boolean) => void;
|
setShowActivity: (show: boolean) => void;
|
||||||
pushActivities: (activities: Activity[]) => void;
|
pushActivities: (activities: Activity[]) => void;
|
||||||
setSourceActivities: (source: string, activities: Activity[]) => void;
|
setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +85,25 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
setSourceActivities: (source, activities) => {
|
setSourceActivities: (source, activities, opts) => {
|
||||||
if (activities.length === 0) bySource.delete(source);
|
if (activities.length === 0) bySource.delete(source);
|
||||||
else bySource.set(source, activities);
|
else bySource.set(source, activities);
|
||||||
const merged = Array.from(bySource.values())
|
const merged = Array.from(bySource.values())
|
||||||
.flat()
|
.flat()
|
||||||
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
|
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
|
||||||
get().pushActivities(merged);
|
|
||||||
|
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: () => {
|
reset: () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user