fix: soundboard upload in Electron, and Spotify sync/disappearing/progress
Soundboard: naming a clip used window.prompt, which Electron does not implement — it returned nothing, the flow aborted in silence, and adding a sound worked in the browser while doing nothing at all in the desktop app. Replaced with a two-step field inside the popover, identical in both. Spotify, three separate defects behind the two symptoms reported: Out of sync — a 20s poll stacked on the activity store's 5s debounce left everyone else on the previous track for up to 25s. The next poll is now scheduled just past the current track's end instead of on a fixed interval, and a track change bypasses the debounce (it happens once every few minutes; the debounce exists for chatty producers). Vanishing — a paused track, and the silent gap Spotify reports between two songs, both cleared the activity outright. Pausing is now carried as state rather than absence, and an empty answer is tolerated for 25s before the block comes down. Progress bar — timestamps are computed with the server's clock and were drawn against the viewer's, so any drift displaced the bar; and it kept advancing after a pause until the next poll. The ready payload now carries server time so each client can correct its own offset, and the bar freezes when paused. Tray, native notifications and system audio in screen share were all found already implemented and wired end to end; recorded in the roadmap rather than built again.
This commit is contained in:
+12
-8
@@ -91,16 +91,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 |
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,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