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.
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { Activity } from '@backspace/shared';
|
|
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
|
|
import { wsSendAll } from '../hooks/useWebSocket';
|
|
|
|
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
interface ActivityState {
|
|
userActivities: Map<string, Activity[]>;
|
|
showActivity: boolean;
|
|
myActivities: Activity[] | null;
|
|
|
|
setUserActivities: (userId: string, activities: Activity[]) => void;
|
|
clearUserActivities: (userId: string) => void;
|
|
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
|
setShowActivity: (show: boolean) => void;
|
|
pushActivities: (activities: Activity[]) => void;
|
|
setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
/**
|
|
* Activities kept per producer. The desktop process detector and Spotify report
|
|
* independently of each other, and a plain replace would let whichever spoke
|
|
* last erase the other — losing precisely the case this exists for: a game and
|
|
* Spotify at the same time.
|
|
*/
|
|
const bySource = new Map<string, Activity[]>();
|
|
|
|
export const useActivityStore = create<ActivityState>((set, get) => ({
|
|
userActivities: new Map(),
|
|
showActivity: true,
|
|
myActivities: null,
|
|
|
|
setUserActivities: (userId, activities) => {
|
|
set((state) => {
|
|
const next = new Map(state.userActivities);
|
|
if (activities.length === 0) {
|
|
next.delete(userId);
|
|
} else {
|
|
next.set(userId, activities);
|
|
}
|
|
return { userActivities: next };
|
|
});
|
|
},
|
|
|
|
clearUserActivities: (userId) => {
|
|
set((state) => {
|
|
const next = new Map(state.userActivities);
|
|
next.delete(userId);
|
|
return { userActivities: next };
|
|
});
|
|
},
|
|
|
|
initActivities: (activityMap) => {
|
|
set((state) => {
|
|
const next = new Map(state.userActivities);
|
|
for (const [userId, activities] of Object.entries(activityMap)) {
|
|
if (activities.length > 0) {
|
|
next.set(userId, activities);
|
|
} else {
|
|
next.delete(userId);
|
|
}
|
|
}
|
|
return { userActivities: next };
|
|
});
|
|
},
|
|
|
|
setShowActivity: (show) => {
|
|
set({ showActivity: show });
|
|
if (!show) {
|
|
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
|
wsSendAll({ type: 'activity_update', activities: [] });
|
|
set({ myActivities: null });
|
|
}
|
|
},
|
|
|
|
pushActivities: (activities) => {
|
|
if (!get().showActivity) return;
|
|
set({ myActivities: activities });
|
|
if (pushTimer) clearTimeout(pushTimer);
|
|
pushTimer = setTimeout(() => {
|
|
wsSendAll({ type: 'activity_update', activities });
|
|
pushTimer = null;
|
|
}, 5000);
|
|
},
|
|
|
|
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: () => {
|
|
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
|
bySource.clear();
|
|
set({ userActivities: new Map(), showActivity: true, myActivities: null });
|
|
},
|
|
}));
|