diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index 16e00018..8301b76b 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -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) diff --git a/packages/server/src/routes/spotify.ts b/packages/server/src/routes/spotify.ts index a1ba1234..4d6644f0 100644 --- a/packages/server/src/routes/spotify.ts +++ b/packages/server/src/routes/spotify.ts @@ -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 { 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) => { diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index d25eb907..2ce89b45 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -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; const assets: ActivityAssets = {}; diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 18aa4126..3af69cd4 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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 { 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, })); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fdc07a02..4bd69d97 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; voiceRoomStarts?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record; 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; voiceRoomStarts?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record; 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 } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index a631df5d..0b9e9af3 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -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; }; @@ -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('DELETE', '/connections/spotify'), }; diff --git a/packages/web/src/components/ui/ProfileActivity.tsx b/packages/web/src/components/ui/ProfileActivity.tsx index 1af571a5..1c34189a 100644 --- a/packages/web/src/components/ui/ProfileActivity.tsx +++ b/packages/web/src/components/ui/ProfileActivity.tsx @@ -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; diff --git a/packages/web/src/components/voice/SoundboardPopover.tsx b/packages/web/src/components/voice/SoundboardPopover.tsx index 8aed4846..e44be059 100644 --- a/packages/web/src/components/voice/SoundboardPopover.tsx +++ b/packages/web/src/components/voice/SoundboardPopover.tsx @@ -19,6 +19,12 @@ export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPop const [sounds, setSounds] = useState([]); 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(null); + const [pendingName, setPendingName] = useState(''); const fileRef = useRef(null); const panelRef = useRef(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 &&
{error}
} + {pendingFile && ( +
+ + 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" + /> +
+ + +
+
+ )} + {sounds.length === 0 ? (

{t('soundboard.empty')}

) : ( diff --git a/packages/web/src/hooks/useSpotifyActivity.ts b/packages/web/src/hooks/useSpotifyActivity.ts index 5220b767..46497c0a 100644 --- a/packages/web/src/hooks/useSpotifyActivity.ts +++ b/packages/web/src/hooks/useSpotifyActivity.ts @@ -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); }; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 11c7363d..dbd43751 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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) { diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts index 14461441..ae474805 100644 --- a/packages/web/src/i18n/locales/en.ts +++ b/packages/web/src/i18n/locales/en.ts @@ -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.', diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts index 8e0b2f5b..11d94fe8 100644 --- a/packages/web/src/i18n/locales/pt-BR.ts +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -39,6 +39,8 @@ export const ptBR: Partial = { '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.', diff --git a/packages/web/src/stores/activityStore.ts b/packages/web/src/stores/activityStore.ts index a784cc14..9dec39a0 100644 --- a/packages/web/src/stores/activityStore.ts +++ b/packages/web/src/stores/activityStore.ts @@ -15,7 +15,7 @@ interface ActivityState { initActivities: (activityMap: Record) => 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((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); - 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: () => { diff --git a/packages/web/src/utils/serverTime.ts b/packages/web/src/utils/serverTime.ts new file mode 100644 index 00000000..a46869a4 --- /dev/null +++ b/packages/web/src/utils/serverTime.ts @@ -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; +}